一个完整可跑的 AJAX 联系表单
很多网站的「联系我们」还停留在整页刷新:填完点提交,页面一跳,报错信息没了一半,用户耐心也没了。换成 AJAX 异步提交后,前端先做即时验证,再用 fetch 把数据发给后端,整个过程不刷新页面,体验接近原生应用。下面这份组件包含 HTML、CSS、JavaScript 三部分,复制即用:前端验证、实时错误提示、加载状态、蜜罐反垃圾一应俱全,后端只要返回约定好的 JSON 就能对接。
一、HTML 表单结构
表单用 novalidate 关掉浏览器默认校验,把验证逻辑统一交给 JavaScript,这样错误提示的样式和文案都能完全自控。蜜罐字段(.honeypot)对用户不可见,自动填充的爬虫却会填上它——后端只要发现该字段有值,就判定为垃圾提交直接丢弃:
<form class="contact-form" id="contactForm" novalidate>
<div class="form-group">
<label for="name">姓名 *</label>
<input type="text" id="name" name="name" required minlength="2"
placeholder="请输入您的姓名">
<span class="error-message" id="nameError"></span>
</div>
<div class="form-group">
<label for="email">邮箱 *</label>
<input type="email" id="email" name="email" required
placeholder="请输入您的邮箱">
<span class="error-message" id="emailError"></span>
</div>
<div class="form-group">
<label for="message">留言 *</label>
<textarea id="message" name="message" required minlength="10"
rows="5" placeholder="请输入留言内容(至少 10 个字符)"></textarea>
<span class="error-message" id="messageError"></span>
</div>
<!-- 蜜罐字段 -->
<input type="text" name="website" class="honeypot" tabindex="-1" autocomplete="off">
<button type="submit" class="submit-btn" id="submitBtn">
<span class="btn-text">发送留言</span>
<span class="btn-loading" style="display:none">发送中...</span>
</button>
</form>
<div id="formSuccess" class="form-success" style="display:none">
<h3>✓ 感谢您的留言!</h3>
<p>我们会尽快回复您。</p>
</div>
二、CSS 样式
样式部分重点处理两类状态:聚焦(focus)和错误(error)。聚焦时边框变色加光晕,错误时边框转红、行内提示文字出现。配合 .error-message 预留的最小高度,提示出现时布局不会跳动。加载中状态由 JavaScript 切换按钮的 disabled 和内容显示:
.contact-form {
max-width: 600px;
margin: 0 auto;
padding: 32px;
background: #f9fafb;
border-radius: 12px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 500;
color: #374151;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 10px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 16px;
transition: border-color .2s;
box-sizing: border-box;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #4F46E5;
box-shadow: 0 0 0 3px rgba(79,70,229,.1);
}
.form-group input.error,
.form-group textarea.error {
border-color: #ef4444;
}
.error-message {
display: block;
margin-top: 4px;
font-size: 14px;
color: #ef4444;
min-height: 20px;
}
.honeypot {
display: none !important;
}
.submit-btn {
width: 100%;
padding: 12px 24px;
background: #4F46E5;
color: #fff;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background .2s;
}
.submit-btn:hover { background: #4338CA; }
.submit-btn:disabled { opacity: .6; cursor: not-allowed; }
.form-success {
text-align: center;
padding: 32px;
background: #f0fdf4;
border-radius: 12px;
color: #166534;
}
三、JavaScript 逻辑
逻辑分三段:失焦与输入时的实时验证、提交时的整体校验、以及 fetch 异步提交与状态切换。验证规则集中在 validateField 里管理,新增字段只需在 fields 数组里加一项;提交前再对所有字段整体校验一次,避免跳过中间字段:
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('contactForm');
const submitBtn = document.getElementById('submitBtn');
const btnText = submitBtn.querySelector('.btn-text');
const btnLoading = submitBtn.querySelector('.btn-loading');
// 实时验证
const fields = ['name', 'email', 'message'];
fields.forEach(field => {
const input = document.getElementById(field);
input.addEventListener('blur', () => validateField(field));
input.addEventListener('input', () => {
const error = document.getElementById(field + 'Error');
if (error.textContent) validateField(field);
});
});
function validateField(field) {
const input = document.getElementById(field);
const error = document.getElementById(field + 'Error');
let message = '';
switch (field) {
case 'name':
if (!input.value.trim()) message = '请输入姓名';
else if (input.value.trim().length < 2) message = '姓名至少 2 个字符';
break;
case 'email':
if (!input.value.trim()) message = '请输入邮箱';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.value))
message = '邮箱格式不正确';
break;
case 'message':
if (!input.value.trim()) message = '请输入留言';
else if (input.value.trim().length < 10) message = '留言至少 10 个字符';
break;
}
error.textContent = message;
input.classList.toggle('error', !!message);
return !message;
}
// 提交表单
form.addEventListener('submit', async (e) => {
e.preventDefault();
// 验证所有字段
const valid = fields.every(validateField);
if (!valid) return;
// 显示加载状态
submitBtn.disabled = true;
btnText.style.display = 'none';
btnLoading.style.display = 'inline';
try {
const formData = new FormData(form);
const data = Object.fromEntries(formData);
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
form.style.display = 'none';
document.getElementById('formSuccess').style.display = 'block';
} else {
alert(result.error || '提交失败,请稍后重试');
}
} catch (error) {
alert('网络错误,请检查连接后重试');
} finally {
submitBtn.disabled = false;
btnText.style.display = 'inline';
btnLoading.style.display = 'none';
}
});
});
功能特性
- ✅ 实时输入验证(失焦 + 输入时)
- ✅ 异步提交(无页面刷新)
- ✅ 加载状态(按钮禁用 + 加载动画)
- ✅ 错误提示(字段级别 + 全局)
- ✅ 成功反馈(替换表单为成功消息)
- ✅ 蜜罐反垃圾保护
- ✅ 配合后端 PHP/Node.js/Flask 使用
后端对接约定
前端只负责提交,后端必须返回固定结构的 JSON。约定两种响应:
// 成功
{ "success": true, "message": "已收到,我们会尽快回复" }
// 失败
{ "error": "邮箱格式不正确,请检查后重试" }
后端处理要点:
- 校验不能只靠前端:前端验证只是体验层,真正的校验(邮箱格式、长度、黑名单)必须在服务端再做一遍
- 蜜罐字段检查:
website字段有值即拒绝,静默返回成功即可,不必告知爬虫 - 限流与去重:同一 IP 短时间内大量提交直接丢弃,并记录日志
- 用队列发邮件:把发信任务丢进队列异步处理,别让用户等 SMTP 握手
一个可参考的 PHP 后端(PHP 8 + PDO,发信走队列):
<?php
// api/contact.php —— 记得在服务器端重新校验所有字段
$json = json_decode(file_get_contents('php://input'), true);
if (!empty($json['website'])) { // 蜜罐被填充 → 静默丢弃
http_response_code(200);
echo json_encode(['success' => true]);
exit;
}
$email = filter_var($json['email'] ?? '', FILTER_VALIDATE_EMAIL);
if (!$email || mb_strlen($json['message'] ?? '') < 10) {
http_response_code(422);
echo json_encode(['error' => '参数不合法']);
exit;
}
// 写入队列表,由后台 worker 异步发信,避免阻塞请求
$stmt = $pdo->prepare('INSERT INTO mail_queue (to_email, subject, body) VALUES (?,?,?)');
$stmt->execute([$to, '新留言', "来自 {$json['name']}: " . $json['message']]);
echo json_encode(['success' => true]);
要点:返回 422 表示参数错误、200 表示成功,前端据此切换提示;蜜罐字段一律静默放行,不给爬虫任何反馈。
页面集成步骤
把组件接进现有网站,按下面四步走,最后一步最容易漏:
- 贴 HTML:把表单和成功提示放进页面模板,
id保持全局唯一,避免和页面里其他元素冲突。 - 引 CSS:直接复制到样式表末尾即可;
.honeypot的display: none别删,它是反垃圾的第一道防线。 - 复制 JS:把
fetch('/api/contact')改成你自己的后端地址,fields数组按表单字段增减。 - 后端返回约定 JSON:用
{ success: true }或{ error: '...' },前端据此切换成功或失败界面。
补充一点无障碍细节:错误提示的 span 建议加 role="alert",屏幕阅读器会即时播报;提交按钮禁用时,配合 aria-disabled 而不是只改样式,键盘用户才不会困惑。
移动端还有个小坑:表单控件字号保持在 16px 以上,可以避免 iOS 在输入时自动放大页面——这个问题在真实项目里比想象中常见。
常见问题
为什么用 fetch 而不是 form.submit()? 整页提交会丢失当前输入、整页闪烁,错误提示也没法内联展示。fetch 让页面状态完全可控,配合 FormData 还能直接拿到结构化数据。
如何防止重复提交? 请求期间按钮 disabled 是第一道防线,服务端再用请求 ID 或时间戳去重做第二道,双保险。
跨域怎么办? 前后端不同域时,后端要配置 CORS(如 Flask 用 flask-cors),否则浏览器会拦截响应。
参考:MDN 表单指南 https://developer.mozilla.org/zh-CN/docs/Learn/Forms ;MDN fetch() https://developer.mozilla.org/zh-CN/docs/Web/API/Fetch_API