表单与表单验证
问题
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/submit、https://... |
method | HTTP 方法 | 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>
邮箱地址
<input type="email" name="email" />
</label>
无需 for 和 id,但 HTML 结构耦合更强。
label 的作用:
- 扩大点击区域 -- 点击 label 文字等同于点击对应的表单控件,对复选框、单选按钮尤其重要
- 屏幕阅读器 -- 视障用户使用屏幕阅读器时,label 会被朗读出来,告知用户输入框的用途
- 可访问性合规 -- 符合 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 来控制表单验证:
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 创建与读取
// 方式一:从 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 提交
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-Type,boundary 会丢失,服务端将无法正确解析数据。
6.3 文件上传
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 转换为普通对象
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 原理
- 受控组件
- 非受控组件(ref + FormData)
- React 19 form action
受控组件的值由 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,表单字段多时需关注性能。
非受控组件通过 ref 或 FormData 在提交时获取值:
import { type FormEvent } from 'react';
function UncontrolledForm() {
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const data = Object.fromEntries(formData.entries());
console.log('提交数据:', data);
};
return (
<form onSubmit={handleSubmit}>
<input name="username" defaultValue="" />
<input name="email" type="email" defaultValue="" />
<button type="submit">提交</button>
</form>
);
}
优点:不触发额外 re-render,性能好,代码简洁。 缺点:难以实现实时校验和字段联动。
React 19 引入了 form action,结合 useActionState 实现声明式表单处理:
import { useActionState } from 'react';
interface FormState {
message: string;
errors?: Record<string, string>;
}
async function submitAction(prevState: FormState, formData: FormData): Promise<FormState> {
const username = formData.get('username') as string;
const email = formData.get('email') as string;
// 服务端校验
if (!username || username.length < 3) {
return { message: '', errors: { username: '用户名至少 3 个字符' } };
}
// 提交到 API
await fetch('/api/register', { method: 'POST', body: formData });
return { message: '注册成功!' };
}
function React19Form() {
const [state, formAction, isPending] = useActionState(submitAction, { message: '' });
return (
<form action={formAction}>
<input name="username" required minLength={3} />
{state.errors?.username && <p className="error">{state.errors.username}</p>}
<input name="email" type="email" required />
<button type="submit" disabled={isPending}>
{isPending ? '提交中...' : '注册'}
</button>
{state.message && <p className="success">{state.message}</p>}
</form>
);
}
React 19 form action 的优势:
- 自动管理 pending 状态(
isPending) - 支持渐进增强(Progressive Enhancement),JavaScript 未加载时表单也能工作
- 与 Server Components / Server Actions 无缝衔接
- 简化了异步提交逻辑
更多 React 19 新特性请参考:React 19 新特性
八、第三方表单库
实际项目中,复杂表单通常使用第三方库来处理验证和状态管理。
更多关于表单引擎的设计思路可参考:设计表单引擎
| 特性 | React Hook Form | Formik | Zod |
|---|---|---|---|
| 定位 | 表单状态管理 + 验证 | 表单状态管理 + 验证 | Schema 验证库 |
| 核心理念 | 非受控优先 | 受控优先 | 类型安全的 Schema 定义 |
| 性能 | 优秀(非受控减少 re-render) | 一般(受控频繁 re-render) | - |
| 包体积 | ~9KB | ~13KB | ~14KB |
| TypeScript | 原生支持 | 支持但体验一般 | 原生 TypeScript-first |
| 验证方案 | 内置 + resolver(Zod/Yup) | Yup 集成 | 独立 Schema 验证 |
| 学习曲线 | 中 | 低 | 低 |
| 推荐程度 | 新项目首选 | 旧项目维护 | 配合 RHF 使用 |
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>
);
}
- 类型安全:
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(文件选择,支持 accept 和 multiple)、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() 会追加。
常见场景:
- 文件上传:
FormData天然支持File对象,配合fetch时浏览器自动设置multipart/form-data编码 - 收集表单数据:
new FormData(form)一次性获取所有带name的控件值 - 转换为 JSON:
Object.fromEntries(formData.entries())转为普通对象后JSON.stringify
面试关键点:使用 fetch 发送 FormData 时不要手动设置 Content-Type,否则 boundary 会丢失导致服务端解析失败。
Q4: label 标签有什么作用?如何正确使用?
答案:
<label> 的核心作用是将文字描述与表单控件建立语义关联,它在可访问性和用户体验上至关重要。
三大作用:
- 扩大点击区域:点击 label 等于点击关联的 input,对小尺寸的 checkbox/radio 尤其有用
- 屏幕阅读器支持:视障用户使用屏幕阅读器时,会朗读 label 的文本内容
- 语义化:搜索引擎和辅助工具可以理解表单结构
两种关联方式:
<!-- 方式一: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(useState) | DOM 自身(ref / FormData) |
| 更新方式 | onChange + setState | 提交时通过 ref.current.value 或 new 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 / XHR | e.preventDefault() 后手动发请求(SPA 中最常用) |
method 属性:
| 方法 | 数据位置 | 长度限制 | 适用场景 |
|---|---|---|---|
GET | URL query string | ~2KB(浏览器限制) | 搜索、筛选、无副作用操作 |
POST | 请求体 | 无限制 | 提交数据、文件上传 |
enctype 属性(仅 POST 有效):
| enctype | 编码方式 | 使用场景 |
|---|---|---|
application/x-www-form-urlencoded | key=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: 前端表单验证和后端验证的关系?为什么需要双重验证?
答案:
核心结论:前端验证是用户体验,后端验证是安全保障,两者缺一不可。
前端验证的作用:
- 即时反馈,用户不必等待服务器响应
- 减少无效请求,降低服务器压力
- 提升用户体验(如实时密码强度提示、格式校验)
后端验证必不可少的原因:
- 前端代码可绕过:攻击者可以通过 DevTools 修改 HTML(删除
required、pattern)、直接用 curl/Postman 发请求、禁用 JavaScript - 前端校验规则可篡改:前端 JavaScript 是公开的,攻击者可以看到校验逻辑并绕过
- 数据安全:防止 SQL 注入、XSS 等攻击必须在后端处理
- 业务逻辑校验:如「用户名是否已注册」、「库存是否充足」等必须查询数据库
最佳实践:
// 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 中),既保证一致性又减少重复代码。永远不要信任前端传来的数据,后端必须独立验证。