表单与表单校验
表单是用户与网站交互的主要入口。本文覆盖表单的组成、事件体系、数据收集,以及"原生校验 + 自定义校验 + 提交流程"的完整方案,让表单既好用又安全。
一、表单的组成
一个典型表单由 <form> 容器和各类控件组成:
<form id="register" novalidate>
<div>
<label for="username">用户名</label>
<input type="text" id="username" name="username" required>
</div>
<div>
<label for="email">邮箱</label>
<input type="email" id="email" name="email" required>
</div>
<div>
<label for="city">城市</label>
<select id="city" name="city">
<option value="beijing">北京</option>
<option value="shanghai">上海</option>
</select>
</div>
<div>
<label for="bio">简介</label>
<textarea id="bio" name="bio"></textarea>
</div>
<button type="submit">提交</button>
</form>| 控件 | 标签 | 取值方式 | 典型属性 |
|---|---|---|---|
| 单行文本 | <input type="text"> | value | maxlength、placeholder |
| 密码 | <input type="password"> | value | autocomplete |
| 单选 | <input type="radio"> | 选中项的 value | 同 name 互斥 |
| 复选 | <input type="checkbox"> | checked / value | checked 属性 |
| 下拉 | <select><option> | value / selectedIndex | multiple |
| 多行文本 | <textarea> | value | rows、cols |
表单内的控件可以通过 form.elements 或 name 直接访问:
const form = document.querySelector("#register");
form.elements.username; // 按 name 取控件
form.elements["city"]; // 数组索引方式
form.elements.length; // 控件数量二、表单相关事件
| 事件 | 触发时机 | 典型用途 |
|---|---|---|
submit | 提交时(点提交按钮或回车) | 拦截提交做校验 |
change | 失焦且值改变时 | 校验、联动 |
input | 每次输入都触发 | 实时搜索、实时校验 |
focus / blur | 获得/失去焦点 | 提示信息显隐 |
invalid | 校验未通过时 | 自定义错误样式 |
const form = document.querySelector("#register");
const email = document.querySelector("#email");
email.addEventListener("input", () => {
console.log("每次键入:", email.value); // 实时
});
email.addEventListener("change", () => {
console.log("失焦且值变化:", email.value); // 事后触发
});
form.addEventListener("submit", (event) => {
event.preventDefault(); // 先阻止默认提交
console.log("开始提交");
});| 对比项 | input | change |
|---|---|---|
| 触发频率 | 每次键入 | 失焦且值有变时 |
| 适合场景 | 实时搜索、实时校验 | 失焦校验、值联动 |
三、FormData 与取值
FormData 把表单内容自动收集为键值对,可读、可追加、可直接用于上传:
const form = document.querySelector("#register");
form.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(form);
data.get("username"); // 单个值
data.getAll("hobby"); // 多值(同名控件)
data.has("email"); // 是否存在
data.append("source", "web"); // 追加额外字段
data.set("username", "新值"); // 覆盖
data.delete("bio"); // 删除
// 遍历
for (const [key, value] of data.entries()) {
console.log(key, value);
}
});| 方法 | 作用 |
|---|---|
get(name) | 取第一个值 |
getAll(name) | 取全部值(数组) |
has(name) | 判断是否存在 |
append(name, value) | 追加(不覆盖已有) |
set(name, value) | 设置(覆盖) |
delete(name) | 删除 |
entries() | 迭代器,遍历键值对 |
小技巧:
new FormData(form)传入表单元素即可自动收集;不传则得到空对象,用append手动添加。- 复选框未选中时不会出现在 FormData 中,用
has()判断后再处理默认值。 - 文件控件
type="file"的值为File对象,FormData 可以直接承载实现文件上传。
四、HTML5 原生校验属性
无需 JS 就能完成基础校验,配合 CSS 伪类展示状态:
<input type="email" required maxlength="50" pattern="[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$">
<input type="number" min="1" max="100" step="1">
<input type="text" required minlength="4" maxlength="20">| 属性 | 作用 | 示例 |
|---|---|---|
required | 必填 | <input required> |
type | 内置格式校验 | type="email"、type="url"、type="number" |
pattern | 正则匹配 | pattern="\d{11}" |
min / max | 数值/日期范围 | min="1" max="100" |
minlength / maxlength | 长度范围 | minlength="4" |
step | 步长 | step="1" |
CSS 伪类根据校验状态自动切换样式:
input:valid { border-color: green; }
input:invalid { border-color: red; }
input:focus:invalid { box-shadow: 0 0 4px rgba(255, 0, 0, 0.4); }原生校验的缺点:错误提示文案不可控(跟随浏览器语言)、样式自定义有限。因此实战常用 novalidate 关闭原生提示框,只借用 :valid / :invalid 做视觉反馈,再由 JS 控制文案。
五、checkValidity 与 setCustomValidity
三个核心 API 掌控校验流程:
| 方法 | 作用 |
|---|---|
form.checkValidity() | 检查整张表单,返回布尔;同时让非法控件触发 invalid 事件 |
input.checkValidity() | 检查单个控件 |
input.setCustomValidity(msg) | 设置自定义错误信息;传空字符串清除 |
input.reportValidity() | 检查并显示浏览器错误气泡 |
const form = document.querySelector("#register");
const username = document.querySelector("#username");
form.addEventListener("submit", (event) => {
event.preventDefault();
// 自定义校验:用户名不允许包含特殊字符
if (!/^[a-zA-Z0-9_]{4,16}$/.test(username.value)) {
username.setCustomValidity("用户名需为 4-16 位字母、数字或下划线");
} else {
username.setCustomValidity(""); // 必须清除,否则一直不通过
}
if (form.checkValidity()) {
submitForm(form);
} else {
form.reportValidity(); // 弹出第一个错误提示
}
});setCustomValidity 设置后该控件会一直处于 :invalid 状态,校验逻辑变了要及时清空。reportValidity() 会把错误信息显示为原生气泡并聚焦第一个非法控件。
六、自定义 JS 校验:正则与规则表
原生校验覆盖不了业务规则时(如"两次密码一致""密码强度"),写一个规则校验器:
const validators = {
email(value) {
return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value);
},
phone(value) {
// 中国大陆手机号:1 开头,第二位 3-9,共 11 位
return /^1[3-9]\d{9}$/.test(value);
},
password(value) {
// 强度:至少 8 位,含大小写字母和数字
return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(value);
},
};
console.log(validators.email("test@example.com")); // true
console.log(validators.phone("13800138000")); // true
console.log(validators.password("Abc12345")); // true规则驱动的校验函数,返回错误信息或 null:
function validateField(name, value, options = {}) {
if (options.required && !value.trim()) return "该项为必填";
if (options.minLength && value.length < options.minLength) {
return `长度至少 ${options.minLength} 位`;
}
if (options.pattern && !options.pattern.test(value)) {
return options.message || "格式不正确";
}
return null; // 通过
}
const error = validateField("password", "abc", {
required: true,
minLength: 8,
pattern: validators.password,
message: "密码需至少 8 位,含大小写字母和数字",
});七、实时校验与防抖
输入过程中实时反馈体验更好,但高频校验(尤其带正则或请求)需要防抖:
function debounce(fn, delay = 300) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
const username = document.querySelector("#username");
const tip = document.querySelector("#username-tip");
const checkName = debounce(() => {
const error = validateField("username", username.value, {
required: true,
pattern: /^[a-zA-Z0-9_]{4,16}$/,
message: "4-16 位字母、数字或下划线",
});
tip.textContent = error || "格式正确";
tip.classList.toggle("error", Boolean(error));
}, 300);
username.addEventListener("input", checkName);| 场景 | 处理方式 |
|---|---|
| 输入框实时校验 | input 事件 + 防抖 300ms |
| 失焦后确认 | blur / change 事件 |
| 校验有网络请求(如用户名是否被占用) | 防抖 + 请求序号比对,丢弃过期响应 |
| 整表提交 | submit 时全量校验 |
"用户名是否被占用"这类服务端校验要注意响应乱序:用序号或取消机制,保证只采纳最新一次请求的结果。
八、提交处理:阻止默认、序列化与 fetch
提交的完整链路:阻止默认 → 校验 → 收集数据 → 发请求 → 处理响应:
const form = document.querySelector("#register");
form.addEventListener("submit", async (event) => {
event.preventDefault(); // 关键:阻止浏览器默认刷新跳转
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const data = new FormData(form);
data.append("source", "web");
try {
const response = await fetch("/api/register", {
method: "POST",
body: data, // FormData 自动设置 multipart 编码,无需手动指定 Content-Type
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
console.log("注册成功:", result);
} catch (error) {
console.error("提交失败:", error);
}
});几种提交体格式对比:
| 格式 | 构造方式 | Content-Type | 适合场景 |
|---|---|---|---|
| FormData | new FormData(form) | multipart/form-data | 含文件上传 |
| URLSearchParams | new URLSearchParams(data) | application/x-www-form-urlencoded | 简单键值 |
| JSON | JSON.stringify(obj) | application/json | API 接口 |
// JSON 提交写法
const payload = {
username: form.elements.username.value,
email: form.elements.email.value,
};
const response = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});九、多控件状态管理
复杂表单需要处理"互相影响"的控件:如省份-城市联动、勾选后启用某字段:
const form = document.querySelector("#register");
const subscribe = document.querySelector("#subscribe");
const emailField = document.querySelector("#email");
// 勾选"订阅"才启用邮箱输入
function syncState() {
emailField.disabled = !subscribe.checked;
emailField.required = subscribe.checked;
if (!subscribe.checked) emailField.value = "";
}
subscribe.addEventListener("change", syncState);
syncState(); // 初始化
// 省份-城市联动
const province = document.querySelector("#province");
const city = document.querySelector("#city");
province.addEventListener("change", () => {
const cities = { beijing: ["海淀", "朝阳"], zhejiang: ["杭州", "宁波"] };
city.replaceChildren(...(cities[province.value] || []).map((c) => {
const opt = document.createElement("option");
opt.value = opt.textContent = c;
return opt;
}));
});| 状态 | 处理要点 |
|---|---|
| 禁用控件 | disabled 后不参与校验,值也不进 FormData |
| 只读控件 | readonly 可读但不可改 |
| 多选/单选 | querySelectorAll('input[name=x]') 遍历找 checked |
十、综合示例:完整注册表单
<form id="register" novalidate>
<input type="email" id="email" name="email" required>
<input type="text" id="username" name="username" required>
<input type="password" id="password" name="password" required>
<button type="submit">注册</button>
<p id="error"></p>
</form>const form = document.querySelector("#register");
const errorBox = document.querySelector("#error");
form.addEventListener("submit", async (event) => {
event.preventDefault();
errorBox.textContent = "";
const value = (name) => form.elements[name].value.trim();
const rules = {
email: validators.email(value("email")),
username: /^[a-zA-Z0-9_]{4,16}$/.test(value("username")),
password: validators.password(value("password")),
};
if (!rules.email) errorBox.textContent = "邮箱格式不正确";
else if (!rules.username) errorBox.textContent = "用户名格式不正确";
else if (!rules.password) errorBox.textContent = "密码强度不足";
else {
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: value("email"),
username: value("username"),
password: value("password"),
}),
});
errorBox.textContent = res.ok ? "注册成功" : "注册失败,请重试";
}
});表单开发的核心链路:原生属性做基础校验、checkValidity 做统一判断、正则与规则表做业务校验、FormData 收集数据、fetch 完成异步提交。交互细节上注意 input 与 change 的时机差异、防抖控制频率、disabled 控件不参与校验,表单就能既顺畅又可靠。