跳到主要内容

表单与表单验证

问题

HTML 表单有哪些重要特性?如何进行表单验证?

答案

一、表单元素总览

HTML 提供了丰富的表单元素,用于构建用户交互界面:

元素说明常见用途
<form>表单容器包裹所有表单控件,定义提交行为
<input>输入控件文本、密码、复选框、单选等
<select>下拉选择单选/多选下拉列表
<textarea>多行文本大段文字输入
<button>按钮提交、重置、普通按钮
<label>标签关联表单控件,提升可访问性
<fieldset>字段集分组表单控件
<legend>字段集标题<fieldset> 添加标题
<output>输出结果显示计算结果
<datalist>候选列表<input> 提供自动补全选项
<progress>进度条显示任务进度
<meter>度量器显示已知范围内的标量值
表单元素综合示例
<form action="/api/register" method="POST">
<fieldset>
<legend>用户注册</legend>

<label for="username">用户名</label>
<input type="text" id="username" name="username" required minlength="3" />

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

<label for="bio">个人简介</label>
<textarea id="bio" name="bio" rows="4"></textarea>

<label for="role">角色</label>
<select id="role" name="role">
<option value="user">普通用户</option>
<option value="admin">管理员</option>
</select>

<label for="city">城市</label>
<input type="text" id="city" name="city" list="city-list" />
<datalist id="city-list">
<option value="北京" />
<option value="上海" />
<option value="广州" />
<option value="深圳" />
</datalist>

<button type="submit">注册</button>
<button type="reset">重置</button>
</fieldset>
</form>

二、input 类型

HTML5 大幅扩展了 <input>type 属性,不同类型会触发不同的浏览器行为(如键盘类型、原生校验、日期选择器等):

type说明移动端键盘原生校验
text单行文本标准键盘
password密码输入(掩码显示)标准键盘
email邮箱地址@ 键盘校验邮箱格式
url网址.com 键盘校验 URL 格式
number数字输入数字键盘校验 min/max/step
tel电话号码电话键盘无(需 pattern)
date日期选择日期选择器校验日期格式
time时间选择时间选择器校验时间格式
datetime-local日期时间日期时间选择器校验格式
range滑块-校验 min/max
color颜色选择颜色选择器校验颜色格式
file文件上传文件选择器accept 过滤
hidden隐藏字段-
checkbox复选框-required
radio单选按钮-required
search搜索框带搜索键键盘
移动端优化

选择正确的 type 非常重要 -- 它决定了移动端弹出的键盘类型。例如 type="tel" 会弹出数字拨号键盘,type="email" 会显示带 @ 符号的键盘,可以大幅提升用户输入体验。

三、表单属性

<form> 元素支持以下关键属性:

属性说明常用值
action表单提交的 URL/api/submithttps://...
methodHTTP 方法GET(查询)、POST(提交)
enctype编码类型见下表
novalidate禁用浏览器原生验证布尔属性
autocomplete自动补全on / off
name表单名称用于 JS 引用
target提交目标_self_blank

enctype 编码类型对比:

enctype 值说明使用场景
application/x-www-form-urlencoded默认值,键值对编码普通表单提交
multipart/form-data二进制数据传输文件上传必须使用
text/plain纯文本(不编码)极少使用
文件上传表单
<form action="/api/upload" method="POST" enctype="multipart/form-data">
<input type="file" name="avatar" accept="image/*" />
<button type="submit">上传</button>
</form>
注意

上传文件时 必须 设置 enctype="multipart/form-data",否则服务端只会收到文件名字符串而非文件内容。

四、label 的重要性

<label> 是表单可访问性的核心元素,它将文字标签与表单控件建立关联。

两种关联方式:

使用 for 属性
<label for="email">邮箱地址</label>
<input type="email" id="email" name="email" />

for 属性的值必须与对应 <input>id 一致。

label 的作用:

  1. 扩大点击区域 -- 点击 label 文字等同于点击对应的表单控件,对复选框、单选按钮尤其重要
  2. 屏幕阅读器 -- 视障用户使用屏幕阅读器时,label 会被朗读出来,告知用户输入框的用途
  3. 可访问性合规 -- 符合 WCAG 标准的基本要求
常见错误

使用 placeholder 代替 label 是一个严重的可访问性问题。placeholder 在用户开始输入后消失,且屏幕阅读器对其支持不一致。每个表单控件都应有对应的 <label>

更多可访问性内容可参考:语义化与可访问性

五、原生验证(Constraint Validation API)

HTML5 内置了一套强大的表单验证机制,无需 JavaScript 即可实现基础验证。

5.1 验证属性

属性说明适用类型
required必填所有输入类型
pattern正则匹配text、tel、email、url、search
min / max最小/最大值number、range、date、time
minlength / maxlength最小/最大长度text、textarea、password
step步进值number、range、date、time
原生验证示例
<form>
<!-- 必填 + 最小长度 -->
<input type="text" name="username" required minlength="3" maxlength="20" />

<!-- 正则匹配:手机号 -->
<input type="tel" name="phone" pattern="^1[3-9]\d{9}$" title="请输入有效的手机号" />

<!-- 数值范围 -->
<input type="number" name="age" min="1" max="150" step="1" />

<!-- 邮箱(type="email" 自带格式验证) -->
<input type="email" name="email" required />

<button type="submit">提交</button>
</form>

5.2 CSS 验证伪类

浏览器会根据验证状态自动为表单元素添加伪类:

伪类说明
:valid通过验证
:invalid未通过验证
:required必填字段
:optional可选字段
:in-range值在 min/max 范围内
:out-of-range值超出 min/max 范围
:placeholder-shown显示 placeholder 时(未输入)
:user-invalid用户交互后未通过验证(较新)
验证状态样式
/* 验证通过 */
input:valid {
border-color: #10b981;
}

/* 验证失败 */
input:invalid {
border-color: #ef4444;
}

/* 仅在用户交互后显示错误状态(避免初始就飘红) */
input:user-invalid {
border-color: #ef4444;
background-color: #fef2f2;
}

/* 必填字段标记 */
input:required + label::after {
content: ' *';
color: #ef4444;
}
:user-invalid:invalid 的区别

:invalid 在页面加载时就会生效(空的 required 字段立刻飘红),体验不好。:user-invalid 只在用户实际交互过后才触发,是更推荐的做法。但要注意 :user-invalid 是较新的伪类,旧浏览器可能不支持。

5.3 Constraint Validation API

JavaScript 提供了一组 API 来控制表单验证:

Constraint Validation API
const form = document.querySelector('form') as HTMLFormElement;
const emailInput = document.querySelector('#email') as HTMLInputElement;

// 1. checkValidity() - 检查是否通过验证,返回 boolean
const isValid: boolean = emailInput.checkValidity();

// 2. reportValidity() - 检查并显示浏览器原生的错误提示气泡
emailInput.reportValidity();

// 3. setCustomValidity() - 设置自定义错误消息
emailInput.addEventListener('input', () => {
if (emailInput.value && !emailInput.value.endsWith('@company.com')) {
emailInput.setCustomValidity('请使用公司邮箱(@company.com)');
} else {
// 传空字符串表示验证通过
emailInput.setCustomValidity('');
}
});

// 4. validity 对象 - 获取详细的验证状态
const validity: ValidityState = emailInput.validity;
console.log({
valueMissing: validity.valueMissing, // required 但未填写
typeMismatch: validity.typeMismatch, // 类型不匹配(如 email 格式错误)
patternMismatch: validity.patternMismatch, // pattern 不匹配
tooLong: validity.tooLong, // 超出 maxlength
tooShort: validity.tooShort, // 不足 minlength
rangeUnderflow: validity.rangeUnderflow, // 小于 min
rangeOverflow: validity.rangeOverflow, // 大于 max
stepMismatch: validity.stepMismatch, // 不符合 step
customError: validity.customError, // setCustomValidity 设置了错误
valid: validity.valid, // 是否全部通过
});

// 5. 表单级别验证
form.addEventListener('submit', (e: Event) => {
if (!form.checkValidity()) {
e.preventDefault();
// 可以自定义错误展示逻辑
form.reportValidity();
}
});

六、FormData API

FormData 是一个用于构造键值对数据的接口,特别适合与 fetch 配合提交表单数据。

6.1 创建与读取

FormData 基本操作
// 方式一:从 form 元素创建,自动收集所有带 name 属性的控件值
const form = document.querySelector('form') as HTMLFormElement;
const formData = new FormData(form);

// 方式二:手动创建
const data = new FormData();
data.append('username', '张三');
data.append('tags', 'frontend');
data.append('tags', 'react'); // 同一个 key 可以 append 多次

// 读取
const username: string | null = data.get('username') as string; // '张三'
const allTags: FormDataEntryValue[] = data.getAll('tags'); // ['frontend', 'react']
const hasEmail: boolean = data.has('email'); // false

// 修改
data.set('username', '李四'); // set 会覆盖,append 会追加
data.delete('tags'); // 删除所有同名键

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

6.2 配合 fetch 提交

FormData + fetch
const form = document.querySelector('#register-form') as HTMLFormElement;

form.addEventListener('submit', async (e: Event) => {
e.preventDefault();
const formData = new FormData(form);

// 提交 FormData 时 **不要** 手动设置 Content-Type
// 浏览器会自动设置为 multipart/form-data 并附带 boundary
const response = await fetch('/api/register', {
method: 'POST',
body: formData,
});

const result = await response.json();
console.log(result);
});
注意

使用 fetch 发送 FormData 时,不要手动设置 Content-Type 请求头。浏览器会自动添加 multipart/form-data 并生成正确的 boundary 分隔符。如果手动设置了 Content-Typeboundary 会丢失,服务端将无法正确解析数据。

6.3 文件上传

FormData 文件上传
const fileInput = document.querySelector('#avatar') as HTMLInputElement;

fileInput.addEventListener('change', async () => {
const file: File | undefined = fileInput.files?.[0];
if (!file) return;

const formData = new FormData();
formData.append('avatar', file, file.name);
formData.append('userId', '12345');

const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});

const result = await response.json();
console.log('上传成功:', result.url);
});

6.4 FormData 转换为普通对象

FormData 转对象
const formData = new FormData(form);

// 简单转换(不处理重复 key)
const obj = Object.fromEntries(formData.entries());

// 处理重复 key(如多选框)
const fullObj: Record<string, FormDataEntryValue | FormDataEntryValue[]> = {};
for (const [key, value] of formData.entries()) {
if (fullObj[key]) {
// 已存在则转为数组
fullObj[key] = Array.isArray(fullObj[key])
? [...(fullObj[key] as FormDataEntryValue[]), value]
: [fullObj[key] as FormDataEntryValue, value];
} else {
fullObj[key] = value;
}
}

// 转换为 JSON 发送
await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.fromEntries(formData)),
});

七、React 中的表单

在 React 中,表单处理主要分为受控组件非受控组件两种模式。

更多关于 React Hooks 的内容可参考:React Hooks 原理

受控组件的值由 React state 管理,每次输入都会触发 onChange 更新状态:

受控组件
import { useState, type FormEvent, type ChangeEvent } from 'react';

interface FormData {
username: string;
email: string;
}

function ControlledForm() {
const [formData, setFormData] = useState<FormData>({ username: '', email: '' });

const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};

const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log('提交数据:', formData);
};

return (
<form onSubmit={handleSubmit}>
<input name="username" value={formData.username} onChange={handleChange} />
<input name="email" type="email" value={formData.email} onChange={handleChange} />
<button type="submit">提交</button>
</form>
);
}

优点:实时获取值、方便做即时校验和联动。 缺点:每次输入都触发 re-render,表单字段多时需关注性能。

八、第三方表单库

实际项目中,复杂表单通常使用第三方库来处理验证和状态管理。

更多关于表单引擎的设计思路可参考:设计表单引擎

特性React Hook FormFormikZod
定位表单状态管理 + 验证表单状态管理 + 验证Schema 验证库
核心理念非受控优先受控优先类型安全的 Schema 定义
性能优秀(非受控减少 re-render)一般(受控频繁 re-render)-
包体积~9KB~13KB~14KB
TypeScript原生支持支持但体验一般原生 TypeScript-first
验证方案内置 + resolver(Zod/Yup)Yup 集成独立 Schema 验证
学习曲线
推荐程度新项目首选旧项目维护配合 RHF 使用
React Hook Form + Zod 示例
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

// 1. 定义 Zod Schema
const registerSchema = z.object({
username: z.string().min(3, '用户名至少 3 个字符').max(20),
email: z.string().email('邮箱格式不正确'),
password: z.string().min(8, '密码至少 8 位'),
confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
message: '两次密码不一致',
path: ['confirmPassword'],
});

// 2. 从 Schema 推导 TypeScript 类型
type RegisterForm = z.infer<typeof registerSchema>;

function RegisterPage() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
});

const onSubmit = async (data: RegisterForm) => {
await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
};

return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('username')} placeholder="用户名" />
{errors.username && <span>{errors.username.message}</span>}

<input {...register('email')} type="email" placeholder="邮箱" />
{errors.email && <span>{errors.email.message}</span>}

<input {...register('password')} type="password" placeholder="密码" />
{errors.password && <span>{errors.password.message}</span>}

<input {...register('confirmPassword')} type="password" placeholder="确认密码" />
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}

<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '提交中...' : '注册'}
</button>
</form>
);
}
React Hook Form + Zod 的优势
  • 类型安全z.infer<typeof schema> 自动推导出表单类型,无需手动维护 interface
  • Schema 复用:同一个 Zod schema 可以同时用于前端验证和后端验证(如 tRPC / Next.js Server Actions)
  • 高性能:React Hook Form 基于非受控组件,只在提交和校验时 re-render

常见面试问题

Q1: HTML 表单有哪些常用的 input 类型?各自的作用?

答案

HTML5 提供了超过 20 种 input 类型,面试中重点掌握以下几类:

文本输入类text(单行文本)、password(密码掩码)、search(搜索框,部分浏览器显示清除按钮)、tel(电话号码,移动端弹出拨号键盘)。

格式校验类email(自带邮箱格式验证)、url(自带 URL 格式验证)、number(数值输入,支持 min/max/step)。

日期时间类date(日期选择器)、time(时间选择器)、datetime-local(日期时间选择器)。

选择类checkbox(复选框)、radio(同 name 的单选按钮互斥)、file(文件选择,支持 acceptmultiple)、color(颜色选择器)、range(滑块)。

特殊类型hidden(隐藏字段,不显示但会提交,常用于 CSRF token)。

面试关键点:选择正确的 type 不仅提供原生校验,更重要的是优化移动端键盘体验提升可访问性

Q2: 如何使用 HTML5 原生表单验证?有哪些验证属性?

答案

HTML5 原生验证由验证属性CSS 伪类Constraint Validation API 三部分组成。

验证属性

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

<!-- pattern: 正则匹配(需配合 title 给出提示) -->
<input type="tel" pattern="^1[3-9]\d{9}$" title="请输入有效手机号" />

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

<!-- minlength/maxlength: 字符长度 -->
<input type="text" minlength="2" maxlength="20" />

<!-- step: 步进值 -->
<input type="number" step="0.01" />

CSS 伪类:valid:invalid:required:optional:in-range:out-of-range。推荐使用 :user-invalid(用户交互后才生效,避免页面加载就报红)。

Constraint Validation API

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

// 检查是否通过验证
input.checkValidity(); // 返回 boolean
input.reportValidity(); // 返回 boolean 并显示原生提示

// 自定义错误消息
input.setCustomValidity('请使用公司邮箱');

// 详细验证状态
input.validity.valueMissing; // required 未填
input.validity.typeMismatch; // 类型不匹配
input.validity.patternMismatch; // pattern 不匹配

面试关键点:原生验证适合简单场景,复杂业务建议 novalidate + JavaScript 自定义校验或使用 React Hook Form 等库。

Q3: FormData API 怎么用?常见场景有哪些?

答案

FormData 是浏览器原生的键值对数据接口,主要用于构造表单数据并通过 fetch / XMLHttpRequest 提交。

创建方式

// 从 form 元素自动收集
const formData = new FormData(document.querySelector('form')!);

// 手动构建
const data = new FormData();
data.append('name', '张三');
data.append('avatar', fileInput.files![0]);

常用方法get()getAll()set()append()delete()has()entries()。其中 set() 会覆盖同名键,append() 会追加。

常见场景

  1. 文件上传FormData 天然支持 File 对象,配合 fetch 时浏览器自动设置 multipart/form-data 编码
  2. 收集表单数据new FormData(form) 一次性获取所有带 name 的控件值
  3. 转换为 JSONObject.fromEntries(formData.entries()) 转为普通对象后 JSON.stringify

面试关键点:使用 fetch 发送 FormData不要手动设置 Content-Type,否则 boundary 会丢失导致服务端解析失败。

Q4: label 标签有什么作用?如何正确使用?

答案

<label> 的核心作用是将文字描述与表单控件建立语义关联,它在可访问性和用户体验上至关重要。

三大作用

  1. 扩大点击区域:点击 label 等于点击关联的 input,对小尺寸的 checkbox/radio 尤其有用
  2. 屏幕阅读器支持:视障用户使用屏幕阅读器时,会朗读 label 的文本内容
  3. 语义化:搜索引擎和辅助工具可以理解表单结构

两种关联方式

<!-- 方式一:for + id 关联(推荐) -->
<label for="email">邮箱</label>
<input type="email" id="email" />

<!-- 方式二:嵌套 -->
<label>
邮箱
<input type="email" />
</label>

面试关键点

  • 每个表单控件都应有对应的 label,这是 WCAG 的基本要求
  • 不要用 placeholder 代替 label,placeholder 在用户输入后消失,且屏幕阅读器支持不一致
  • for 属性方式更灵活,label 和 input 不必在 DOM 中相邻

Q5: React 中受控表单和非受控表单有什么区别?React 19 的 form action 是什么?

答案

对比项受控组件非受控组件
数据源React state(useStateDOM 自身(ref / FormData
更新方式onChange + setState提交时通过 ref.current.valuenew FormData()
实时获取值可以(state 同步更新)不能(提交时才读取)
即时校验容易实现难以实现
性能每次输入触发 re-render不触发额外 re-render
适用场景需要实时联动、即时校验简单表单、性能敏感场景

React 19 form action

React 19 引入了 <form action={asyncFunction}> + useActionState,这是一种全新的表单处理范式:

const [state, formAction, isPending] = useActionState(
async (prevState, formData: FormData) => {
// 异步处理逻辑
return { message: '成功' };
},
{ message: '' }
);

<form action={formAction}>
<input name="field" />
<button disabled={isPending}>提交</button>
</form>

核心优势

  • 自动管理 isPending 状态
  • 支持渐进增强(JS 未加载时表单也能提交)
  • Server Actions 无缝配合,前后端共享验证逻辑
  • 代码更声明式,减少 e.preventDefault() 等样板代码

Q6: 如何实现自定义的表单验证提示?

答案

浏览器默认的验证提示气泡样式固定且无法自定义。要实现自定义提示,有以下方案:

方案一:setCustomValidity + 原生气泡

const input = document.querySelector('#phone') as HTMLInputElement;

input.addEventListener('input', () => {
if (input.value && !/^1[3-9]\d{9}$/.test(input.value)) {
input.setCustomValidity('请输入有效的手机号码');
} else {
input.setCustomValidity(''); // 清空 = 验证通过
}
});

// 提交时触发
input.reportValidity(); // 显示气泡

方案二:novalidate + 完全自定义 UI

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

// 禁用原生验证 UI,但验证逻辑仍然可用
form.setAttribute('novalidate', '');

form.addEventListener('submit', (e: Event) => {
e.preventDefault();
const errors: string[] = [];

// 手动检查每个字段
const inputs = form.querySelectorAll('input');
inputs.forEach((input: HTMLInputElement) => {
if (!input.checkValidity()) {
// 用 validity 对象判断具体错误类型
if (input.validity.valueMissing) {
errors.push(`${input.name} 是必填项`);
} else if (input.validity.typeMismatch) {
errors.push(`${input.name} 格式不正确`);
} else if (input.validity.patternMismatch) {
errors.push(`${input.name} 不符合要求`);
}
// 添加自定义错误样式
input.classList.add('error');
}
});

if (errors.length > 0) {
showCustomErrors(errors); // 自定义错误展示
} else {
form.submit();
}
});

面试关键点:实际项目中通常设置 novalidate 禁用原生 UI,然后结合 validity 对象做自定义校验展示。React 项目推荐使用 React Hook Form + Zod 方案。

Q7: 表单提交有哪些方式?action/method/enctype 各是什么?

答案

表单提交方式

方式说明
原生提交<form action="/api" method="POST"> + <button type="submit">
JavaScript 提交form.submit()form.requestSubmit()
fetch / XHRe.preventDefault() 后手动发请求(SPA 中最常用)

method 属性

方法数据位置长度限制适用场景
GETURL query string~2KB(浏览器限制)搜索、筛选、无副作用操作
POST请求体无限制提交数据、文件上传

enctype 属性(仅 POST 有效):

enctype编码方式使用场景
application/x-www-form-urlencodedkey=value&key2=value2默认,普通表单
multipart/form-data二进制分段传输文件上传必须用
text/plain不编码几乎不使用

submit() vs requestSubmit() 的区别

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

// submit() 直接提交,跳过验证和 submit 事件
form.submit();

// requestSubmit() 触发验证和 submit 事件(推荐)
form.requestSubmit();

面试关键点:SPA 中通常拦截原生提交(e.preventDefault()),使用 fetch 发送请求。文件上传务必使用 multipart/form-data

Q8: 前端表单验证和后端验证的关系?为什么需要双重验证?

答案

核心结论:前端验证是用户体验,后端验证是安全保障,两者缺一不可。

前端验证的作用

  • 即时反馈,用户不必等待服务器响应
  • 减少无效请求,降低服务器压力
  • 提升用户体验(如实时密码强度提示、格式校验)

后端验证必不可少的原因

  1. 前端代码可绕过:攻击者可以通过 DevTools 修改 HTML(删除 requiredpattern)、直接用 curl/Postman 发请求、禁用 JavaScript
  2. 前端校验规则可篡改:前端 JavaScript 是公开的,攻击者可以看到校验逻辑并绕过
  3. 数据安全:防止 SQL 注入、XSS 等攻击必须在后端处理
  4. 业务逻辑校验:如「用户名是否已注册」、「库存是否充足」等必须查询数据库

最佳实践

前后端共享验证 Schema(Zod)
// shared/schemas.ts - 前后端共享
import { z } from 'zod';

export const registerSchema = z.object({
username: z.string().min(3).max(20),
email: z.string().email(),
password: z.string().min(8),
});

export type RegisterInput = z.infer<typeof registerSchema>;
前端使用
// 前端:React Hook Form + Zod
import { registerSchema } from '@shared/schemas';
const { register, handleSubmit } = useForm({
resolver: zodResolver(registerSchema),
});
后端使用
// 后端:NestJS / Next.js Server Action
import { registerSchema } from '@shared/schemas';

async function registerUser(input: unknown) {
const data = registerSchema.parse(input); // 验证失败会抛出 ZodError
// 继续业务逻辑...
}

面试关键点:Zod 等 Schema 验证库可以让前后端共享同一份验证逻辑(尤其在 Monorepo 中),既保证一致性又减少重复代码。永远不要信任前端传来的数据,后端必须独立验证。


相关链接