Skip to content

DOM 表单取值与校验 ​

新增学员页面需要读取姓名、班级、手机号、地址和状态,再整理成 API 需要的 JSON 数据。提交之前,还要检查必填项和字段格式。

本节课解决四个问题:

  1. 怎样读取不同类型的表单控件。
  2. 怎样把表单内容整理成 JavaScript 对象。
  3. 怎样使用 HTML 和 JavaScript 完成浏览器端校验。
  4. 怎样把错误显示在对应字段旁边。

本节按“读取控件 → 整理数据 → 校验并显示错误”的顺序推进。当前只在浏览器中预检查数据,不发送 API 请求;后面的 fetch 章节再完成真正提交。

前面的短示例围绕相应的 HTML 分别练习;同一控件的不同取值方案用于对比,不要把重复声明的变量全部放进一个脚本。综合案例的完整实现代码则按顺序组合。

表单取值 ​

name 与字段名 ​

表单控件要有 name。name 不只是 HTML 属性,也是前端整理数据和后端接收字段时的重要名称。

html
<form id="student-form">
  <label for="student-name">姓名</label>
  <input id="student-name" name="name" type="text" />

  <label for="class-name">班级</label>
  <select id="class-name" name="class_name">
    <option value="">请选择班级</option>
    <option value="Python 1 班">Python 1 班</option>
    <option value="Python 2 班">Python 2 班</option>
  </select>

  <button type="submit">保存</button>
</form>

接口字段是 class_name,表单中也使用 name="class_name",这样 FormData 收集后的字段可以直接对应接口。id="class-name" 用于关联标签和查询 DOM,不承担请求字段的职责。前面本地对象使用的 className 可以保留,但提交前必须明确转换为 class_name,不能把两者当成同一个属性读取。

文本控件取值 ​

输入框和多行文本框的当前内容都保存在 value 中。

html
<h1 id="page-title">新增学员</h1>
<input id="student-name" type="text" value="张三" />
<textarea id="student-address">湖南长沙</textarea>
js
const nameInput = document.querySelector("#student-name");
const addressInput = document.querySelector("#student-address");
const pageTitle = document.querySelector("#page-title");

const name = nameInput.value.trim();
const address = addressInput.value.trim();

console.log(name);
console.log(address);

运行后,控制台会得到去掉两侧空格后的姓名和地址。表单控件的当前输入使用 value,不要用 textContent 读取。

表单输入使用 value,普通元素文字使用 textContent:

js
nameInput.value = "李四";
pageTitle.textContent = "编辑学员";

提交普通文本前通常调用 trim(),去掉两侧多余空格。

js
const trimmedName = nameInput.value.trim();

if (!trimmedName) {
  console.log("请输入姓名");
}

trim() 不会修改输入框原值,只返回处理后的新字符串。如果希望页面也显示处理结果,需要重新赋值:

js
nameInput.value = nameInput.value.trim();

form.elements 查询 ​

已经找到表单后,可以通过 form.elements 按 name 查询内部控件。

js
const studentForm = document.querySelector("#student-form");

const nameInput = studentForm.elements.namedItem("name");
const classSelect = studentForm.elements.namedItem("class_name");

console.log(nameInput.value);
console.log(classSelect.value);

也经常写成:

js
console.log(studentForm.elements.name.value);
console.log(studentForm.elements.class_name.value);

namedItem() 更明确,也能避免字段名与对象自带属性发生混淆。本课程的完整案例使用 namedItem()。

单选框取值 ​

同一组单选框使用相同 name,不同 value。

html
<form id="student-form">
  <label>
    <input type="radio" name="gender" value="male" />
    男
  </label>
  <label>
    <input type="radio" name="gender" value="female" />
    女
  </label>
</form>

:checked 查询

js
const checkedGender = document.querySelector(
  'input[name="gender"]:checked',
);

const gender = checkedGender ? checkedGender.value : "";

没有选中项时,querySelector() 返回 null,所以不能直接读取 .value。

form.elements 读取

js
const studentForm = document.querySelector("#student-form");
const genderFromForm = studentForm.elements.gender.value;

同名的多个单选框会组成 RadioNodeList,读取 .value 可以得到当前选中值。没有选中时得到空字符串。

页面上只需要临时查找选中项时可以使用 :checked;已经拿到整个表单时,使用 form.elements 更集中。两种方式都要处理“没有选中”的情况。

复选框取值 ​

单个复选框

学员状态开关只有一个控件,直接读取 checked:

html
<label>
  <input id="student-disabled" name="disabled" type="checkbox" />
  停用学员
</label>
js
const disabledInput = document.querySelector("#student-disabled");
const disabled = disabledInput.checked;

console.log(disabled); // true 或 false

value 表示控件提交时使用的字符串,checked 才表示当前是否选中。

一组复选框

html
<label><input type="checkbox" name="skills" value="html" />HTML</label>
<label><input type="checkbox" name="skills" value="css" />CSS</label>
<label><input type="checkbox" name="skills" value="javascript" />JavaScript</label>

读取全部选中值:

js
const checkedSkills = document.querySelectorAll(
  'input[name="skills"]:checked',
);

const skills = Array.from(checkedSkills, (input) => input.value);
console.log(skills);

querySelectorAll() 返回 NodeList。Array.from() 可以在转换为数组的同时读取每个控件的 value。

下拉框取值 ​

普通单选下拉框直接读取 value:

html
<select id="class-name" name="class_name">
  <option value="">请选择班级</option>
  <option value="Python 1 班">Python 1 班</option>
  <option value="Python 2 班">Python 2 班</option>
</select>
js
const classSelect = document.querySelector("#class-name");
console.log(classSelect.value);

多选下拉框使用 selectedOptions:

html
<select id="course-select" name="courses" multiple>
  <option value="html">HTML</option>
  <option value="css">CSS</option>
  <option value="javascript">JavaScript</option>
</select>
js
const courseSelect = document.querySelector("#course-select");
const courses = Array.from(
  courseSelect.selectedOptions,
  (option) => option.value,
);

普通下拉框只有一个值,读取 value 即可;带 multiple 的下拉框可能选中多项,需要遍历 selectedOptions。

文件控件取值 ​

文件输入框不能通过普通 value 得到可上传文件。使用 files:

html
<input id="avatar" name="avatar" type="file" accept="image/*" />
js
const avatarInput = document.querySelector("#avatar");
const avatarFile = avatarInput.files[0];

if (avatarFile) {
  console.log(avatarFile.name);
  console.log(avatarFile.type);
  console.log(avatarFile.size);
}

files 是 FileList。没有选择文件时,files[0] 是 undefined。

本节只读取文件信息。图片预览、大小检查、FormData 上传放到后面的文件上传课。

FormData 字段收集 ​

如果控件都在同一个 <form> 中,并且有 name,可以创建 FormData:

js
const studentForm = document.querySelector("#student-form");
const formData = new FormData(studentForm);

console.log(formData.get("name"));
console.log(formData.get("class_name"));

普通单值字段可以转换成对象:

js
const data = Object.fromEntries(formData.entries());
console.log(data);

但要注意:

  • 所有普通字段值默认都是字符串。
  • 未选中的复选框不会出现在 FormData 中。
  • 同名复选框需要使用 formData.getAll(name)。
  • 没有 name 的控件不会被收集。
  • disabled 控件不会被收集。
  • 文件字段得到的是 File 对象。
js
const skills = formData.getAll("skills");
const disabled = formData.has("disabled");

当前学员 API 使用 JSON,并要求 disabled 是布尔值,所以完整案例会主动整理数据对象,不直接使用 Object.fromEntries() 作为最终结果。

FormData 很适合快速查看浏览器会收集哪些字段,也适合后面的文件上传;当前 JSON 案例仍然手动整理数据,确保字段名、字符串处理和布尔值都符合接口要求。

表单校验与错误反馈 ​

HTML 约束校验 ​

浏览器原生支持常用校验属性:

html
<input
  name="name"
  type="text"
  required
  minlength="2"
  maxlength="20"
/>

<input
  name="mobile"
  type="tel"
  required
  pattern="1[3-9][0-9]{9}"
/>
属性作用
required必填
minlength最少字符数
maxlength最多字符数
min、max数字或日期范围
pattern使用正则表达式检查格式
type="email"检查基础邮箱格式
type="url"检查基础 URL 格式

浏览器提交表单时,会自动检查这些约束。

使用 JavaScript 主动检查:

js
const studentForm = document.querySelector("#student-form");

console.log(studentForm.checkValidity());
studentForm.reportValidity();
方法作用
checkValidity()返回是否通过,不主动弹出全部提示
reportValidity()检查并显示浏览器提示

前端校验只用于尽早提醒用户,后端仍然必须重新校验。用户可以绕过浏览器代码直接调用 API。

validity 校验状态 ​

每个支持校验的表单控件都有 validity:

js
const mobileInput = studentForm.elements.namedItem("mobile");

console.log(mobileInput.validity.valid);
console.log(mobileInput.validity.valueMissing);
console.log(mobileInput.validity.patternMismatch);
console.log(mobileInput.validationMessage);

常用状态:

状态含义
valid全部约束通过
valueMissingrequired 字段为空
tooShort少于 minlength
tooLong超过 maxlength
typeMismatch不符合 email、url 等类型
patternMismatch不符合 pattern
rangeUnderflow小于 min
rangeOverflow大于 max
customErrorsetCustomValidity() 设置了错误

这些状态适合根据不同错误显示更清楚的中文提示。

setCustomValidity 自定义提示 ​

setCustomValidity() 可以设置业务错误:

js
const mobileInput = studentForm.elements.namedItem("mobile");

mobileInput.setCustomValidity("");

if (!/^1[3-9]\d{9}$/.test(mobileInput.value.trim())) {
  mobileInput.setCustomValidity("请输入正确的 11 位手机号");
}

mobileInput.reportValidity();

每次重新校验前必须先清空旧错误:

js
mobileInput.setCustomValidity("");

如果没有清空,即使用户已经修改正确,控件仍然会保持无效状态。

字段错误反馈 ​

管理后台通常需要把错误固定显示在字段下面,而不是只使用浏览器气泡。

html
<div class="form-item">
  <label for="student-name">姓名</label>
  <input
    id="student-name"
    name="name"
    type="text"
    aria-describedby="name-error"
  />
  <small id="name-error" class="form-error"></small>
</div>

显示错误:

js
function showFieldError(input, errorElement, message) {
  input.classList.add("is-invalid");
  input.setAttribute("aria-invalid", "true");
  errorElement.textContent = message;
}

清除错误:

js
function clearFieldError(input, errorElement) {
  input.classList.remove("is-invalid");
  input.removeAttribute("aria-invalid");
  errorElement.textContent = "";
}

错误文字使用 textContent,不要使用 innerHTML。

aria-describedby="name-error" 把输入框与说明区域关联起来;aria-invalid="true" 表示当前输入有误。两者不会替你执行校验,也不会自动显示文字,需要与上面的样式和错误消息一起更新。

提交失败时,先显示全部字段错误,再把焦点移到第一个无效控件;清除错误时同步移除 aria-invalid。这样键盘用户能直接修改问题,辅助技术也能读到对应说明。后面接收 API 的 422 错误时,仍可复用这些字段旁的错误区域。

综合案例:学员表单预检查 ​

提交表单时完成下面的步骤:

  1. 阻止浏览器刷新页面。
  2. 读取并整理学员数据。
  3. 清除上一次错误。
  4. 校验姓名、班级、手机号和地址。
  5. 把错误显示到对应字段下面。
  6. 校验通过后,在页面显示将要提交的 JSON。

本案例暂时不调用 API。下一阶段学习 fetch 后,再把数据发送给 POST /api/students。

表单结构 ​

html
<form id="student-form" novalidate>
  <div class="form-item">
    <label for="student-name">姓名</label>
    <input
      id="student-name"
      name="name"
      type="text"
      autocomplete="name"
      aria-describedby="name-error"
    />
    <small id="name-error" class="form-error"></small>
  </div>

  <div class="form-item">
    <label for="class-name">班级</label>
    <select id="class-name" name="class_name" aria-describedby="class-name-error">
      <option value="">请选择班级</option>
      <option value="Python 1 班">Python 1 班</option>
      <option value="Python 2 班">Python 2 班</option>
      <option value="Web 全栈班">Web 全栈班</option>
    </select>
    <small id="class-name-error" class="form-error"></small>
  </div>

  <div class="form-item">
    <label for="student-mobile">手机号</label>
    <input
      id="student-mobile"
      name="mobile"
      type="tel"
      autocomplete="tel"
      aria-describedby="mobile-error"
    />
    <small id="mobile-error" class="form-error"></small>
  </div>

  <div class="form-item">
    <label for="student-address">地址</label>
    <textarea
      id="student-address"
      name="address"
      rows="3"
      aria-describedby="address-error"
    ></textarea>
    <small id="address-error" class="form-error"></small>
  </div>

  <label class="checkbox-label">
    <input name="disabled" type="checkbox" />
    停用学员
  </label>

  <button type="submit">检查数据</button>
  <button id="reset-button" type="button">重置</button>
</form>

<p id="empty-result">请先填写并检查表单</p>
<pre id="result" hidden></pre>

novalidate 表示这个完整案例由 JavaScript 显示自定义错误。四个 aria-describedby 分别关联字段旁的错误区域;脚本显示错误时还会设置 aria-invalid。前面讲解的浏览器原生校验仍然有效,两种方式按项目需要选择。

字段配置 ​

js
const form = document.querySelector("#student-form");
const result = document.querySelector("#result");
const emptyResult = document.querySelector("#empty-result");

const fieldConfig = {
  name: {
    input: form.elements.namedItem("name"),
    error: document.querySelector("#name-error"),
  },
  class_name: {
    input: form.elements.namedItem("class_name"),
    error: document.querySelector("#class-name-error"),
  },
  mobile: {
    input: form.elements.namedItem("mobile"),
    error: document.querySelector("#mobile-error"),
  },
  address: {
    input: form.elements.namedItem("address"),
    error: document.querySelector("#address-error"),
  },
};

fieldConfig 把每个字段与对应错误区域放在一起。后面清除错误、显示错误和聚焦第一个错误字段时,都可以按字段名复用这份配置。

数据整理 ​

js
function getStudentFormData(form) {
  const nameInput = form.elements.namedItem("name");
  const classSelect = form.elements.namedItem("class_name");
  const mobileInput = form.elements.namedItem("mobile");
  const addressInput = form.elements.namedItem("address");
  const disabledInput = form.elements.namedItem("disabled");

  return {
    name: nameInput.value.trim(),
    class_name: classSelect.value,
    mobile: mobileInput.value.trim(),
    address: addressInput.value.trim(),
    disabled: disabledInput.checked,
  };
}

函数返回的是普通 JavaScript 对象:文本字段去掉两侧空格,disabled 保持布尔值。这正是后面发送 JSON 时需要的结构。

数据校验 ​

js
function validateStudentData(data) {
  const errors = {};

  if (data.name.length < 2) {
    errors.name = "姓名至少需要 2 个字符";
  } else if (data.name.length > 20) {
    errors.name = "姓名不能超过 20 个字符";
  }

  if (!data.class_name) {
    errors.class_name = "请选择班级";
  }

  if (!/^1[3-9]\d{9}$/.test(data.mobile)) {
    errors.mobile = "请输入正确的 11 位手机号";
  }

  if (data.address.length > 100) {
    errors.address = "地址不能超过 100 个字符";
  }

  return errors;
}

errors 的键与 fieldConfig 的键保持一致。没有错误时返回空对象;有错误时,例如 errors.mobile 会保存手机号提示文字。

错误与预览状态 ​

js
function clearErrors() {
  for (const config of Object.values(fieldConfig)) {
    config.input.classList.remove("is-invalid");
    config.input.removeAttribute("aria-invalid");
    config.error.textContent = "";
  }
}

function showErrors(errors) {
  for (const [field, message] of Object.entries(errors)) {
    const config = fieldConfig[field];
    config.input.classList.add("is-invalid");
    config.input.setAttribute("aria-invalid", "true");
    config.error.textContent = message;
  }
}

提交前先调用 clearErrors(),再调用 showErrors(errors)。这样用户修正后,旧提示和红色边框不会残留。

清空提交预览

js
function clearResult() {
  result.textContent = "";
  result.hidden = true;
  emptyResult.hidden = false;
}

每次重新检查或重置表单时,先隐藏旧 JSON,恢复“请先填写并检查表单”的提示。

提交与重置 ​

js
form.addEventListener("submit", (event) => {
  event.preventDefault();

  const data = getStudentFormData(form);
  const errors = validateStudentData(data);

  clearErrors();
  clearResult();

  if (Object.keys(errors).length > 0) {
    showErrors(errors);

    const firstErrorField = Object.keys(errors)[0];
    fieldConfig[firstErrorField].input.focus();
    return;
  }

  result.textContent = JSON.stringify(data, null, 2);
  result.hidden = false;
  emptyResult.hidden = true;
});

下面只抽出表单提交的固定结构,供回看时对照,不要和上面的完整监听器重复绑定:

js
form.addEventListener("submit", (event) => {
  event.preventDefault();
  // 读取、校验和处理数据
});

上一节已经学习 submit、事件对象和默认行为,本节把它们用于表单读取与校验。

重置表单

js
const resetButton = document.querySelector("#reset-button");

resetButton.addEventListener("click", () => {
  form.reset();
  clearErrors();
  clearResult();
  form.elements.namedItem("name").focus();
});

重置不仅恢复控件初始值,也要清除错误和旧预览。最后把焦点放回姓名输入框,用户可以直接重新填写。

运行与验收 ​

打开学员表单预检查(源码文件:./.code/p04/c06-form-dom/04-form-precheck.html)后,直接点击“检查数据”,姓名、班级和手机号下方应显示错误,第一个错误字段会自动获得焦点。

可以按下面步骤验证:

  1. 填写姓名“张三”、班级“Python 1 班”、手机号“13800000001”,点击检查,应在右侧看到包含 disabled: false 的 JSON。
  2. 勾选“停用学员”后再次检查,JSON 中的 disabled 应变为布尔值 true,不是字符串 "true"。
  3. 将姓名改为 1 个字符,再改为超过 20 个字符;两次检查都应看到姓名错误。恢复为“张三”后,姓名错误应消失。
  4. 在地址中输入超过 100 个字符,检查后应看到地址错误;删至 100 个字符以内后再次检查,错误应消失。
  5. 输入错误手机号后检查,再改为正确手机号重新检查,红色边框和错误文字应消失。
  6. 点击重置,表单、错误提示和 JSON 预览应恢复初始状态,姓名输入框重新获得焦点。

完整附件还包含页面样式:

学员表单预检查
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>DOM 表单取值与校验</title>
  <style>
    * {
      box-sizing: border-box;
    }

    body {
      margin: 0;
      background: #f5f7fa;
      color: #303133;
      font-family: Arial, "Microsoft YaHei", sans-serif;
    }

    .page-container {
      display: grid;
      grid-template-columns: minmax(0, 560px) minmax(0, 1fr);
      gap: 24px;
      width: min(1040px, calc(100% - 32px));
      margin: 48px auto;
    }

    .form-panel,
    .result-panel {
      padding: 28px;
      border-radius: 10px;
      background: #fff;
      box-shadow: 0 6px 20px rgb(0 0 0 / 6%);
    }

    .panel-title {
      margin: 0 0 24px;
      font-size: 24px;
    }

    .form-item {
      margin-bottom: 18px;
    }

    .form-item label {
      display: block;
      margin-bottom: 8px;
      font-weight: 600;
    }

    .form-item input,
    .form-item select,
    .form-item textarea {
      display: block;
      width: 100%;
      padding: 10px 12px;
      border: 1px solid #dcdfe6;
      border-radius: 5px;
      color: #303133;
      font: inherit;
      outline: none;
    }

    .form-item input:focus,
    .form-item select:focus,
    .form-item textarea:focus {
      border-color: #16baaa;
    }

    .form-item .is-invalid {
      border-color: #f56c6c;
    }

    .form-error {
      display: block;
      min-height: 18px;
      margin-top: 5px;
      color: #f56c6c;
      font-size: 13px;
    }

    .checkbox-label {
      display: inline-flex;
      align-items: center;
      gap: 7px;
      margin-bottom: 22px;
      cursor: pointer;
    }

    .button-group {
      display: flex;
      gap: 10px;
    }

    button {
      padding: 10px 20px;
      border: 1px solid transparent;
      border-radius: 5px;
      font: inherit;
      cursor: pointer;
    }

    button[type="submit"] {
      background: #16baaa;
      color: #fff;
    }

    #reset-button {
      border-color: #dcdfe6;
      background: #fff;
      color: #606266;
    }

    .result-description {
      margin: -12px 0 20px;
      color: #606266;
      line-height: 1.7;
    }

    #result {
      min-height: 240px;
      margin: 0;
      padding: 18px;
      overflow: auto;
      border-radius: 6px;
      background: #1f2329;
      color: #d6e4ff;
      line-height: 1.6;
    }

    .empty-result {
      padding: 70px 20px;
      border: 1px dashed #dcdfe6;
      border-radius: 6px;
      color: #909399;
      text-align: center;
    }

    @media (max-width: 860px) {
      .page-container {
        grid-template-columns: 1fr;
      }
    }
  </style>
</head>
<body>
  <main class="page-container">
    <section class="form-panel">
      <h1 class="panel-title">新增学员</h1>

      <form id="student-form" novalidate>
        <div class="form-item">
          <label for="student-name">姓名</label>
          <input
            id="student-name"
            name="name"
            type="text"
            autocomplete="name"
            aria-describedby="name-error"
            placeholder="请输入姓名"
          />
          <small id="name-error" class="form-error"></small>
        </div>

        <div class="form-item">
          <label for="class-name">班级</label>
          <select
            id="class-name"
            name="class_name"
            aria-describedby="class-name-error"
          >
            <option value="">请选择班级</option>
            <option value="Python 1 班">Python 1 班</option>
            <option value="Python 2 班">Python 2 班</option>
            <option value="Web 全栈班">Web 全栈班</option>
          </select>
          <small id="class-name-error" class="form-error"></small>
        </div>

        <div class="form-item">
          <label for="student-mobile">手机号</label>
          <input
            id="student-mobile"
            name="mobile"
            type="tel"
            autocomplete="tel"
            aria-describedby="mobile-error"
            placeholder="请输入 11 位手机号"
          />
          <small id="mobile-error" class="form-error"></small>
        </div>

        <div class="form-item">
          <label for="student-address">地址</label>
          <textarea
            id="student-address"
            name="address"
            rows="3"
            aria-describedby="address-error"
            placeholder="地址可以不填写"
          ></textarea>
          <small id="address-error" class="form-error"></small>
        </div>

        <label class="checkbox-label">
          <input name="disabled" type="checkbox" />
          停用学员
        </label>

        <div class="button-group">
          <button type="submit">检查数据</button>
          <button id="reset-button" type="button">重置</button>
        </div>
      </form>
    </section>

    <section class="result-panel">
      <h2 class="panel-title">提交预览</h2>
      <p class="result-description">
        校验通过后,这里会显示准备发送给测试 API 的 JSON 数据。
      </p>
      <p id="empty-result" class="empty-result">请先填写并检查表单</p>
      <pre id="result" hidden></pre>
    </section>
  </main>

  <script>
    const form = document.querySelector("#student-form");
    const resetButton = document.querySelector("#reset-button");
    const result = document.querySelector("#result");
    const emptyResult = document.querySelector("#empty-result");

    const fieldConfig = {
      name: {
        input: form.elements.namedItem("name"),
        error: document.querySelector("#name-error"),
      },
      class_name: {
        input: form.elements.namedItem("class_name"),
        error: document.querySelector("#class-name-error"),
      },
      mobile: {
        input: form.elements.namedItem("mobile"),
        error: document.querySelector("#mobile-error"),
      },
      address: {
        input: form.elements.namedItem("address"),
        error: document.querySelector("#address-error"),
      },
    };

    function getStudentFormData(form) {
      const nameInput = form.elements.namedItem("name");
      const classSelect = form.elements.namedItem("class_name");
      const mobileInput = form.elements.namedItem("mobile");
      const addressInput = form.elements.namedItem("address");
      const disabledInput = form.elements.namedItem("disabled");

      return {
        name: nameInput.value.trim(),
        class_name: classSelect.value,
        mobile: mobileInput.value.trim(),
        address: addressInput.value.trim(),
        disabled: disabledInput.checked,
      };
    }

    function validateStudentData(data) {
      const errors = {};

      if (data.name.length < 2) {
        errors.name = "姓名至少需要 2 个字符";
      } else if (data.name.length > 20) {
        errors.name = "姓名不能超过 20 个字符";
      }

      if (!data.class_name) {
        errors.class_name = "请选择班级";
      }

      if (!/^1[3-9]\d{9}$/.test(data.mobile)) {
        errors.mobile = "请输入正确的 11 位手机号";
      }

      if (data.address.length > 100) {
        errors.address = "地址不能超过 100 个字符";
      }

      return errors;
    }

    function clearErrors() {
      for (const config of Object.values(fieldConfig)) {
        config.input.classList.remove("is-invalid");
        config.input.removeAttribute("aria-invalid");
        config.error.textContent = "";
      }
    }

    function showErrors(errors) {
      for (const [field, message] of Object.entries(errors)) {
        const config = fieldConfig[field];
        config.input.classList.add("is-invalid");
        config.input.setAttribute("aria-invalid", "true");
        config.error.textContent = message;
      }
    }

    function clearResult() {
      result.textContent = "";
      result.hidden = true;
      emptyResult.hidden = false;
    }

    form.addEventListener("submit", (event) => {
      event.preventDefault();

      const data = getStudentFormData(form);
      const errors = validateStudentData(data);

      clearErrors();
      clearResult();

      if (Object.keys(errors).length > 0) {
        showErrors(errors);

        const firstErrorField = Object.keys(errors)[0];
        fieldConfig[firstErrorField].input.focus();
        return;
      }

      result.textContent = JSON.stringify(data, null, 2);
      result.hidden = false;
      emptyResult.hidden = true;
    });

    resetButton.addEventListener("click", () => {
      form.reset();
      clearErrors();
      clearResult();
      form.elements.namedItem("name").focus();
    });
  </script>
</body>
</html>

小结 ​

本节课最核心的是掌握六件事:

  1. 文本框、下拉框和多行文本框读取 value。
  2. 单个复选框读取 checked,复选框组查询 :checked。
  3. 文件输入框读取 files,不能依赖普通 value。
  4. 使用独立函数把 DOM 表单整理成 API 需要的数据对象。
  5. HTML 约束适合基础校验,JavaScript 适合项目自定义错误。
  6. 前端校验不能替代后端校验,API 错误仍要单独处理。