表单是网页和用户打交道最多的元素,但"能提交"和"好用的表单"之间差着十万八千里:密码太弱要提醒、邮箱格式错了要即时标红、点提交不能连点两次……本文用纯 JavaScript 实现一个完整的注册表单,把校验、反馈、提交这一整套流程讲透,代码可以直接复制到你的项目里改造使用。

1. 表单的 HTML 骨架

先搭一个最基础的注册表单。每个输入框配一个错误提示元素(默认是空的),用命名规律把输入框和它的错误提示对应起来,方便 JS 成对处理:

<form id="registerForm" novalidate>
  <div>
    <label for="username">用户名</label>
    <input id="username" name="username" type="text">
    <p class="error" id="usernameError"></p>
  </div>
  <div>
    <label for="email">邮箱</label>
    <input id="email" name="email" type="email">
    <p class="error" id="emailError"></p>
  </div>
  <div>
    <label for="password">密码</label>
    <input id="password" name="password" type="password">
    <p class="error" id="passwordError"></p>
  </div>
  <div>
    <label for="confirm">确认密码</label>
    <input id="confirm" name="confirm" type="password">
    <p class="error" id="confirmError"></p>
  </div>
  <button type="submit">注册</button>
</form>

novalidate 关掉浏览器自带的提示气泡,把校验控制权完全交给 JS,这样错误提示的样式和文案才能统一。注意每个输入框都带了 name 属性——这是后面 FormData 能自动收集数据的前提。

还有两个容易被忽略的细节:一是 label 的 for 属性指向输入框 id,点击文字就能聚焦输入框,对鼠标和屏幕阅读器都友好;二是错误提示元素最好加上 aria-live="polite" 标记,这样校验失败时屏幕阅读器会自动读出提示,视力障碍用户也能知道哪里填错了——可访问性不是加分项,是基本要求。

样式上,错误提示默认用红色小字加输入框红边框就足够醒目;如果表单很长,提交失败时还可以用 scrollIntoView 把页面滚到第一个出错字段,用户不用自己满屏找——这个细节很多人做了才知道香。

2. 收集表单数据:FormData

拿到表单 DOM 后,new FormData(form) 会把所有带 name 属性的输入框自动收集起来。配合 Object.fromEntries 能一键转成普通对象,操作起来非常方便:

const form = document.getElementById('registerForm');

form.addEventListener('submit', function (event) {
  event.preventDefault();

  const data = new FormData(form);
  const values = Object.fromEntries(data.entries());
  console.log(values);
});

event.preventDefault() 阻止表单默认的整页刷新提交——这是所有 JS 表单处理的第一步,忘了它,你写的所有校验都会在提交瞬间被"跳过"。FormData 对 checkbox、radio、file 等控件同样适用,比逐个 getElementById 取值省事得多。

FormData 还提供 get()、has()、append() 等方法:data.get('username') 取单个值,data.has('agree') 判断勾选框是否勾上,append 可以追加同名多值(比如多选标签)。处理文件上传时,直接把 <input type="file"> 的 File 对象 append 进去,再以 FormData 作为 body 提交,浏览器会自动设置 multipart 编码——这是上传文件的标准姿势。另外提醒一句:密码框用 type="password" 只是防偷看,数据在网络上依然是明文,真正的安全靠 HTTPS。

3. 用正则定义校验规则

校验规则的本质是"用正则判断字符串是否符合格式"。把规则集中放在一个对象里,再写一个通用的 validate 函数,规则和逻辑就分开了,以后加字段只改一处:

const rules = {
  username: /^[a-zA-Z0-9_]{3,16}$/,
  email: /^[\w.-]+@[\w-]+(\.[\w-]+)+$/,
  password: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,20}$/
};

function validate(field, value) {
  if (!value) {
    return field === 'username' ? '请输入用户名' : '此项不能为空';
  }
  if (!rules[field].test(value)) {
    const messages = {
      username: '用户名需为 3-16 位字母、数字或下划线',
      email: '邮箱格式不正确',
      password: '密码需 8-20 位,且同时包含大小写字母和数字'
    };
    return messages[field];
  }
  return '';
}

console.log(validate('username', 'linwan'));
console.log(validate('email', 'lin@example.com'));
console.log(validate('password', 'abc'));
console.log(validate('password', 'Abc12345'));

密码那条正则是重点:(?=.*[a-z]) 是"零宽断言",它只检查"后面有没有小写字母"而不消耗字符。三个断言叠加,就实现了"必须同时包含小写、大写、数字"的强密码规则,而且顺序无关——这是用单个正则写复杂条件的关键技巧。

正则不是万能的,它只做"格式"检查,做不了"语义"检查。比如邮箱正则能拦住 abc@ 这种明显错误,但拦不住 not-a-real@domain.com 这种格式合法却不存在的地址——那需要发验证邮件才能确认。所以别把正则写得过于复杂,能覆盖 95% 的正常输入就够了,剩下的交给后端。写正则时建议打开 DevTools 控制台,用 /正则/.test('测试字符串') 一行一行试,比写完直接上生产靠谱得多。

4. 实时反馈:输入即校验

只等提交时才报错,体验太差。监听 input 事件,每次输入都重新校验,有错就显示红字,无错就清空提示。这样用户还没点提交,就已经知道哪里不对了:

function bindLiveValidation(inputId, errorId, field) {
  const input = document.getElementById(inputId);
  const error = document.getElementById(errorId);

  input.addEventListener('input', function () {
    const message = validate(field, input.value.trim());
    error.textContent = message;
    input.classList.toggle('invalid', Boolean(message));
  });
}

bindLiveValidation('username', 'usernameError', 'username');
bindLiveValidation('email', 'emailError', 'email');
bindLiveValidation('password', 'passwordError', 'password');

input 事件在每次内容变化时触发,比 change 更及时(change 要等失焦才触发)。classList.toggle('invalid', condition) 的第二个参数是开关:条件为真就加类,为假就移除,比手动 if/else 干净多了,配合 CSS 就能给输入框加红边框。

实时校验有个体验细节:用户刚点进输入框、还没来得及打字时,就弹出"此项不能为空"会很烦。常见的做法是"失焦后开始校验,输入时实时刷新":第一次用 blur 事件触发校验,之后每次 input 都重新校验并更新提示。上面的 bindLiveValidation 其实已经接近这个效果——input 事件只在你真正输入时才触发,配合 trim() 处理首尾空格,基本不会误报。

三种校验时机各有分工:blur 管"离开时提醒",input 管"修改时实时更新",submit 管"提交前最终把关"。组合使用,体验才完整。还有个小细节:校验通过时清空错误提示的同时,别忘了把输入框的 invalid 类也去掉,否则边框会一直红着,用户会以为自己还在报错状态。

5. 提交:总校验 + 防重复 + 发请求

点击提交时,要对所有字段做一次"总校验",任何一项不过就整体拦截。通过后禁用按钮防止重复提交,再用前面学过的 fetch 把数据发给后端——这里是完整流程:

form.addEventListener('submit', async function (event) {
  event.preventDefault();

  const data = new FormData(form);
  const values = Object.fromEntries(data.entries());
  values.username = values.username.trim();
  values.email = values.email.trim();

  const errors = {};
  for (const field of ['username', 'email', 'password']) {
    const message = validate(field, values[field]);
    if (message) {
      errors[field] = message;
    }
  }
  if (values.password !== values.confirm) {
    errors.confirm = '两次输入的密码不一致';
  }

  if (Object.keys(errors).length > 0) {
    console.log('校验失败:', errors);
    return;
  }

  const button = form.querySelector('button[type="submit"]');
  button.disabled = true;
  button.textContent = '提交中…';

  try {
    const response = await fetch('/api/register', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(values)
    });
    if (!response.ok) {
      throw new Error('注册失败,请稍后再试');
    }
    console.log('注册成功!');
  } catch (err) {
    console.error(err.message);
  } finally {
    button.disabled = false;
    button.textContent = '注册';
  }
});

注意两个细节:确认密码的比对放在正则循环之后,因为 confirm 字段没有独立规则,只能做一致性检查;提交期间 disabled 按钮 + finally 恢复,能同时防住"连点"和"失败后无法重试"两个问题。另外请记住,真实项目里后端校验永远不能省——前端校验只是用户体验。

上面的例子把错误打进了 console,真实项目里要把错误显示到页面上——既然错误提示元素已经按"字段名 + Error"命名,遍历 errors 对象就能批量渲染,还能复用上一节的实时校验逻辑,保证"提交时"和"输入时"看到的是同一套提示:

function showErrors(errors) {
  for (const [field, message] of Object.entries(errors)) {
    const errorEl = document.getElementById(field + 'Error');
    if (errorEl) {
      errorEl.textContent = message;
    }
    const input = document.getElementById(field);
    if (input) {
      input.classList.toggle('invalid', Boolean(message));
    }
  }
}

showErrors({ username: '用户名已被占用', email: '邮箱格式不正确' });

showErrors 把字段名映射成输入框和提示元素的 id,一次遍历同时更新两处。field + 'Error' 这种命名约定在这里体现出了价值:只要有统一的命名规则,批量操作就只需要一行循环。把 showErrors 接进提交回调的 errors 分支,错误提示就完整了。

6. 总结与练习

一套完整的表单流程是:HTML 骨架(带错误提示位)→ FormData 收集 → 正则规则表 → input 实时校验 → 提交时总校验 → 防重复 + fetch 发送。规则与逻辑分离、错误信息即时可见,是体验的关键。

练习:

💡 表单校验永远分两层:前端校验管体验,后端校验管安全。任何"前端已经拦过了"的想法,都是安全漏洞的温床。