一个完整可跑的 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": "邮箱格式不正确,请检查后重试" }

后端处理要点:

  1. 校验不能只靠前端:前端验证只是体验层,真正的校验(邮箱格式、长度、黑名单)必须在服务端再做一遍
  2. 蜜罐字段检查website 字段有值即拒绝,静默返回成功即可,不必告知爬虫
  3. 限流与去重:同一 IP 短时间内大量提交直接丢弃,并记录日志
  4. 用队列发邮件:把发信任务丢进队列异步处理,别让用户等 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 表示成功,前端据此切换提示;蜜罐字段一律静默放行,不给爬虫任何反馈。

页面集成步骤

把组件接进现有网站,按下面四步走,最后一步最容易漏:

  1. 贴 HTML:把表单和成功提示放进页面模板,id 保持全局唯一,避免和页面里其他元素冲突。
  2. 引 CSS:直接复制到样式表末尾即可;.honeypotdisplay: none 别删,它是反垃圾的第一道防线。
  3. 复制 JS:把 fetch('/api/contact') 改成你自己的后端地址,fields 数组按表单字段增减。
  4. 后端返回约定 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