
表单代码中的数据校验:必填项、格式与错误提示
表单校验不是把输入框标成红色就算完成。一个可靠的表单需要回答三个问题:哪些数据必须填写,数据格式是否符合业务要求,以及用户怎样修正错误。它还必须考虑一个事实:浏览器中的校验可以改善填写体验,却不能代替服务端校验。请求可以绕过页面直接发送,因此任何会影响业务、安全或数据完整性的规则,都应在可信的服务端重新检查。
本文从浏览器内置校验讲起,再逐步加入自定义提示、提交处理和服务端验证示例。示例使用姓名、邮箱和密码三个字段;实际项目应依据业务规则调整限制,不要把示例中的长度或密码要求当成通用标准。
一、先区分校验发生在哪里
- 浏览器原生校验:使用
required、type、minlength、pattern等 HTML 属性。代码少,支持键盘和辅助技术,适合基础格式和必填检查。 - 前端自定义校验:用 JavaScript 补充跨字段规则、显示更具体的说明,或在提交前组织错误信息。它用于交互体验,不是安全边界。
- 服务端校验:在收到请求后解析数据、验证字段并执行授权与业务规则。它是写入数据库、创建账号或执行其他业务操作前不可省略的一层。
三层校验不必复制成完全相同的实现,但规则应保持一致。前端可以提前提示“邮箱格式不正确”,服务端仍要独立判断输入是否符合要求;前端说“通过”不代表服务端应信任它。
二、从 HTML 原生约束开始
下面是一个可以保存为 form.html 并直接在浏览器打开的完整示例。必填字段使用 required,邮箱使用 type="email",密码设置了示例长度范围。表单的 action 指向示意地址,真正部署时要替换成项目的服务端接口。
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>创建账号</title>
<form action="/register" method="post">
<div>
<label for="name">姓名</label>
<input id="name" name="name" type="text"
required minlength="2" maxlength="40"
autocomplete="name">
</div>
<div>
<label for="email">邮箱</label>
<input id="email" name="email" type="email"
required autocomplete="email">
</div>
<div>
<label for="password">密码</label>
<input id="password" name="password" type="password"
required minlength="12" maxlength="128"
autocomplete="new-password">
</div>
<button type="submit">创建账号</button>
</form>
</html>
浏览器在常规提交时会检查这些约束;不符合要求时通常会阻止提交并显示原生提示。但提示的具体文字、样式和交互由浏览器及操作系统决定,不应假设所有用户看到的文案完全一致。若表单通过 JavaScript 发送请求,开发者还需确认提交代码确实触发并检查约束;直接调用接口或使用其他客户端发送请求则完全不会经过页面校验。
常用约束及其边界
required表示必须提供值。对复选框而言,通常表示必须勾选;对一组单选按钮而言,同名组中需要有一个选中项。它不适用于所有控件,例如隐藏字段不适合用来要求用户输入。type="email"检查的是浏览器定义的邮箱输入格式,不会查询邮箱是否存在、是否可收信,也不能证明填写者拥有该邮箱。邮箱语法本身较复杂,客户端检查不应被当作完整的邮箱验证。minlength和maxlength限制文本长度,但长度的实际计量与复杂字符有关。一个可见字符可能由多个 Unicode 码点组成;若业务规则要求按用户感知字符计数,应明确采用的计数方式,并在前后端保持一致。min、max和step常用于数字、日期等输入类型。它们控制的是表单约束,不是对业务范围的完整证明;服务端仍须检查类型、边界和取值是否合法。pattern用于匹配文本字段的格式约束,适合规则明确且简单的格式,例如固定结构的编号。它不是通用解析器,也不适合用一个庞大正则表达式代替完整业务验证。
有两个容易忽略的细节。第一,required 通常不能阻止只输入空格的文本:空格仍是非空字符串。如果姓名、备注等字段不允许首尾空白,应在前端提示并由服务端按业务规则处理。第二,给 pattern 配置表达式时,要理解浏览器对该属性的匹配规则,并针对真实输入测试;不要未经验证地复制正则。带有 required 的可选格式字段要慎用:字段为空时通常不触发格式约束,非空时才检查格式。
三、必填项不等于“有意义的数据”
必填规则应先从业务含义出发。联系人电话是否必填,取决于服务是否必须通过电话联系用户;同意服务条款是否必须勾选,取决于具体流程和法律要求。不要仅因为字段出现在页面上,就一律加上 required。
还要区分“字段缺失”和“值为空”。接口可能收到没有该属性的对象,也可能收到 null、空字符串或只含空白字符的字符串。这些情况是否等价,应由接口契约决定。服务端解析时要显式检查,而不是只判断对象里有没有键。
对于下拉框,可以提供一个值为空的提示选项,再将选择设为必填;否则如果第一项已经是有效值,用户可能从未做出选择却通过校验。对于复选框,应把“是否必须勾选”与“是否必须填写某个说明”分开建模。多个选项中至少选一个的规则通常需要对整个选项组进行校验,不能随意给每个复选框都加 required,那会变成要求全部勾选。
四、设置便于修正的错误提示
原生提示适合快速搭建,但当产品需要稳定的中文说明、页面内汇总或跨字段规则时,可以用 Constraint Validation API 补充。下面的示例保留浏览器原生约束,先设置具体提示,再调用 reportValidity() 显示错误。它同时校验确认密码是否与密码一致。
<form id="account-form">
<div>
<label for="email">邮箱</label>
<input id="email" name="email" type="email" required>
</div>
<div>
<label for="password">密码</label>
<input id="password" name="password" type="password"
required minlength="12" maxlength="128">
</div>
<div>
<label for="confirm-password">确认密码</label>
<input id="confirm-password" name="confirmPassword"
type="password" required>
</div>
<button type="submit">提交</button>
<p id="form-status" role="status" aria-live="polite"></p>
</form>
<script>
const form = document.querySelector("#account-form");
const email = form.elements.email;
const password = form.elements.password;
const confirmPassword = form.elements.confirmPassword;
const status = document.querySelector("#form-status");
function updateCustomValidity() {
email.setCustomValidity(
email.validity.typeMismatch ? "请输入格式正确的邮箱地址。" : ""
);
password.setCustomValidity(
password.validity.tooShort
? "密码长度不足,请按页面要求填写。"
: ""
);
confirmPassword.setCustomValidity(
confirmPassword.value !== password.value
? "两次输入的密码不一致。"
: ""
);
}
for (const field of [email, password, confirmPassword]) {
field.addEventListener("input", updateCustomValidity);
field.addEventListener("blur", updateCustomValidity);
}
form.addEventListener("submit", (event) => {
updateCustomValidity();
if (!form.reportValidity()) {
event.preventDefault();
status.textContent = "请检查表单中的错误后再提交。";
return;
}
status.textContent = "浏览器校验通过,接下来仍需由服务端验证。";
});
</script>
setCustomValidity("") 表示清除自定义错误;传入非空文本则会让控件处于无效状态。若错误修正后忘记清空自定义消息,字段会一直无法通过校验。示例在输入和失焦时更新规则,并在提交时再次统一检查,以减少状态过期的问题。
这段代码在校验通过时没有调用 preventDefault(),因此表单会按默认行为提交。由于示例没有配置 action,实际测试时应接入可用的提交地址,或在开发环境中自行增加请求逻辑。若改用 fetch() 提交,应明确决定何时调用 reportValidity(),并处理服务端返回的错误;不能只因为提交按钮触发了 JavaScript,就认为浏览器会自动完成所有校验。
五、错误信息要让用户知道怎么改
“输入错误”没有说明具体问题。好的提示通常包括字段名称、问题和可执行的修正方向,例如“邮箱格式不正确,请检查 @ 后面的域名”,或“密码长度不足,请按页面要求补充”。提示应准确,不要向用户暴露内部异常、数据库结构、堆栈信息或安全规则的实现细节。
错误呈现还应兼顾可访问性和移动设备使用:
- 为每个输入框提供可见的
label,并用for与控件的id对应。占位符不能代替标签,因为占位符会在输入后消失,也可能对比度不足。 - 错误文字应靠近相关字段;如果错误已在页面中展示,可通过
aria-describedby将控件与说明关联,并在错误状态设置合适的aria-invalid值。 - 提交失败时,把焦点移到第一个错误字段,或提供清晰的错误汇总和可跳转链接。不要只用颜色区分错误,应该同时提供文字或其他可辨识标记。
- 不要在每次按键时打断用户。格式校验可以在失焦或提交时给出;若需要实时提示,应避免频繁播报和闪烁,让用户有机会完成输入。
- 服务端返回字段错误时,应保留其他已填写内容,并将具体错误映射到对应控件。避免在错误页面中把密码回显出来。
自定义提示并非越多越好。若每个字段都同时展示浏览器提示、页面红字、顶部弹窗和通知栏,用户反而难以判断该处理哪一条。选定一种清晰的错误展示方式,并确保鼠标、键盘、触屏和辅助技术都能获得同一关键信息。
六、跨字段与业务规则如何处理
单字段属性适合必填、基础格式和范围;依赖多个字段的规则需要额外逻辑。例如“结束日期不得早于开始日期”“确认邮箱必须一致”“至少选择一种通知方式”。前端可以尽早提示,但应把同一规则落实到服务端。
日期比较尤其需要先明确时区与业务含义。纯日历日期、带时区的时间戳和本地时间不是同一种数据。若把本地日期转成时间戳比较,可能受到时区或夏令时影响。接口应定义格式、时区和边界规则,不要只依赖浏览器控件返回的显示文本。
数值也应验证是否为有限数、单位是否一致以及边界是否包含端点。把字符串直接转成数字可能遇到空字符串、指数记法、精度损失或非数字值。金额通常应采用明确的货币单位与精度规则,避免用普通浮点运算承担精确金额计算。
七、服务端校验示例
下面用 Node.js 和内置的 http 模块演示服务端如何独立检查 JSON 请求。代码可保存为 server.js,使用 Node.js 运行;客户端应向 POST /register 发送 JSON。示例适合说明校验流程,不包含数据库、登录态、限流或完整的生产级错误处理。
const http = require("node:http");
function sendJson(res, status, body) {
res.writeHead(status, {
"content-type": "application/json; charset=utf-8"
});
res.end(JSON.stringify(body));
}
const server = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/register") {
sendJson(res, 404, { error: "not_found" });
return;
}
let raw = "";
req.on("data", (chunk) => {
raw += chunk;
if (raw.length > 20_000) {
sendJson(res, 413, { error: "request_too_large" });
req.destroy();
}
});
req.on("end", () => {
let data;
try {
data = JSON.parse(raw);
} catch {
sendJson(res, 400, { error: "invalid_json" });
return;
}
if (!data || typeof data !== "object" || Array.isArray(data)) {
sendJson(res, 400, { error: "invalid_body" });
return;
}
const errors = {};
const name = typeof data.name === "string" ? data.name.trim() : "";
const email = typeof data.email === "string" ? data.email.trim() : "";
const password =
typeof data.password === "string" ? data.password : "";
if (name.length < 2 || name.length > 40) {
errors.name = "姓名长度不符合要求。";
}
if (email.length === 0 || email.length > 254 ||
!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email)) {
errors.email = "邮箱格式不符合要求。";
}
if (password.length < 12 || password.length > 128) {
errors.password = "密码长度不符合要求。";
}
if (Object.keys(errors).length > 0) {
sendJson(res, 422, { errors });
return;
}
// 生产环境应继续执行邮箱唯一性检查、密码安全存储等业务逻辑。
sendJson(res, 201, { ok: true });
});
});
server.listen(3000, () => {
console.log("Listening on http://localhost:3000");
});
示例用简单正则说明服务端会重新检查邮箱,但它不是完整的邮箱规范实现,也不能证明邮箱可用。若业务需要确认用户拥有邮箱,应发送验证链接或验证码,并在服务端验证令牌。示例中的字符长度按 JavaScript 字符串的 UTF-16 代码单元计数;如果前端和服务端需要按其他规则计数,应明确选择实现并对齐。
示例为便于阅读,对请求体大小作了简单上限判断。真实服务还应使用框架或经过验证的请求体解析中间件,正确处理超限后的连接状态、请求超时和异常;不应让无限大的请求体持续占用内存。还要限制请求的内容类型,处理不支持的方法,并结合业务需要实施速率限制、日志策略和监控。
服务端验证通过也不代表请求可以直接执行。用户是否有权修改某条记录、是否能执行某项操作,应做独立的授权检查。校验输入形状不能替代身份认证和权限控制;同样,表单中的隐藏字段、只读字段以及客户端计算出的价格都可以被请求发送者修改,不能作为可信依据。
八、校验与安全:哪些能做,哪些不能做
- 不要信任客户端结果。 HTML 属性和 JavaScript 可以被关闭、修改或绕过。服务端必须针对收到的数据验证类型、长度、范围、格式和业务规则。
- 校验不能自动消除注入风险。 输入符合某种格式,不代表把它拼接进 SQL、命令或模板就是安全的。数据库访问应使用参数化查询;输出到 HTML 时应采用适合上下文的编码;执行系统命令应避免拼接不可信输入。
- 长度上限有助于控制资源。 对文本、请求体、文件数量和文件大小设置合理上限,可以减少意外的大输入和资源消耗。但上限的具体数值应按业务、协议和基础设施确定,不能把页面上的
maxlength当作服务器资源保护措施。 - 校验文件不能只看扩展名。 上传服务需结合大小限制、允许类型策略、内容检测、文件名处理、存储隔离和访问控制;客户端的
accept属性只是文件选择器提示,不是安全校验。 - 避免把密码规则写成臆造的安全保证。 长度、字符规则和密码长度范围要参考产品需求及适用的安全标准;服务端应使用适当的密码哈希方案保存密码,不应明文存储或可逆加密后当作密码保护。
- 错误响应不要泄露敏感信息。 对登录、账号是否存在等场景,过于具体的反馈可能被用于枚举账号。应结合威胁模型设计提示,并设置适当的频率限制。
上述建议不是让所有输入都套用同一种“严格过滤”。姓名、地址和自由文本可能包含空格、非拉丁文字或标点。过度限制会误伤合法用户,也不等于安全。应验证字段实际含义,尽量使用明确的数据类型、长度和业务范围;针对输出位置和操作本身,再采用相应的安全措施。
九、常见错误清单
- 只做前端校验。 直接请求接口可以绕过页面。修正方式:服务端对每个写入操作重新验证,并在业务执行前做授权检查。
- 把必填提示当成空白清理。
required不会替业务决定是否应去掉首尾空格。修正方式:分别定义空白处理、规范化和原值保存策略,不要对所有字段盲目使用trim()。 - 认为邮箱输入框能验证真实邮箱。 它只检查基础输入格式。修正方式:需要确认可达性或所有权时,用服务端发送验证信息。
- 在每次输入时只设置错误、不清除错误。 自定义有效性状态会持续影响提交。修正方式:用户修正后重新计算条件,无错误时调用
setCustomValidity("")。 - 使用一个难以维护的正则表达式处理所有情况。 正则表达式容易漏掉边界,也可能造成误判或性能问题。修正方式:选择有明确语义的输入类型、解析器或成熟库,并测试有效和无效样例。
- 只用颜色或弹窗显示失败。 颜色可能无法区分,弹窗也可能打断操作。修正方式:提供邻近字段的文字说明、适当的语义关联和键盘可达的焦点路径。
- 服务端报错后清空整张表单。 用户需要重新输入大量内容,密码也不应为了保留而被回显。修正方式:保留非敏感字段,密码字段留空,并准确呈现字段级错误。
- 只校验格式,不校验权限或状态。 一个格式正确的记录编号不代表当前用户有权访问它。修正方式:把输入验证、身份认证、授权和业务状态检查作为不同步骤处理。
十、落地时的检查顺序
实现表单前,先列出每个字段的类型、是否必填、允许范围、规范化方式以及失败后的用户动作。随后用原生属性覆盖简单而稳定的约束;涉及多个字段或更清楚的提示时,再添加 JavaScript。最后在服务端重新解析和验证数据,并检查权限、状态、资源限制和持久化方式。
测试不能只覆盖“正确填写后能够提交”。还应检查字段缺失、空字符串、全空格、边界长度、超长值、错误类型、特殊字符、重复提交、跨字段不匹配、直接请求接口,以及服务端返回字段错误时的页面行为。若有文件上传、数字精度或国际化姓名等特殊场景,应另外设计针对性的测试。
好的数据校验不是把所有输入挡在门外,而是准确区分可接受的数据与不符合规则的数据,及时告诉用户怎样修正,并在服务端可靠地保护业务边界。浏览器负责尽早反馈,服务端负责可信地判断,两者各司其职,表单才既好用又不依赖用户端的自觉。



