JavaScript|第31章:表单与控件

JavaScript|第31章:表单与控件

表单与控件

  • JavaScript|第31章:表单与控件
    • 引言
    • [31.1 表单与表单元素基础](#31.1 表单与表单元素基础)
      • [31.1.1 form 元素](#31.1.1 form 元素)
      • [31.1.2 表单控件概览](#31.1.2 表单控件概览)
      • [31.1.3 form.elements 与 elements 集合](#31.1.3 form.elements 与 elements 集合)
      • [31.1.4 反向引用:element.form](#31.1.4 反向引用:element.form)
      • [31.1.5 fieldset 与 legend](#31.1.5 fieldset 与 legend)
    • [31.2 表单元素详解](#31.2 表单元素详解)
      • [31.2.1 input 元素](#31.2.1 input 元素)
      • [31.2.2 textarea 元素](#31.2.2 textarea 元素)
      • [31.2.3 select 与 option](#31.2.3 select 与 option)
      • [31.2.4 checkbox 与 radio 的特殊处理](#31.2.4 checkbox 与 radio 的特殊处理)
    • [31.3 聚焦:focus / blur](#31.3 聚焦:focus / blur)
      • [31.3.1 focus 与 blur 事件](#31.3.1 focus 与 blur 事件)
      • [31.3.2 focusin / focusout(冒泡版本)](#31.3.2 focusin / focusout(冒泡版本))
      • [31.3.3 tabindex 与聚焦顺序](#31.3.3 tabindex 与聚焦顺序)
      • [31.3.4 实用:焦点陷阱(Focus Trap)](#31.3.4 实用:焦点陷阱(Focus Trap))
    • [31.4 表单事件:change、input、cut、copy、paste](#31.4 表单事件:change、input、cut、copy、paste)
      • [31.4.1 change 事件](#31.4.1 change 事件)
      • [31.4.2 input 事件](#31.4.2 input 事件)
      • [31.4.3 change vs input 对比](#31.4.3 change vs input 对比)
      • [31.4.4 剪贴板事件](#31.4.4 剪贴板事件)
      • [31.4.5 Clipboard API(异步剪贴板)](#31.4.5 Clipboard API(异步剪贴板))
    • [31.5 表单提交](#31.5 表单提交)
      • [31.5.1 submit 事件](#31.5.1 submit 事件)
      • [31.5.2 提交方法](#31.5.2 提交方法)
      • [31.5.3 FormData 对象](#31.5.3 FormData 对象)
      • [31.5.4 通过 fetch 提交表单](#31.5.4 通过 fetch 提交表单)
    • [31.6 表单验证](#31.6 表单验证)
      • [31.6.1 HTML5 验证属性](#31.6.1 HTML5 验证属性)
      • [31.6.2 Constraint Validation API](#31.6.2 Constraint Validation API)
      • [31.6.3 自定义验证消息](#31.6.3 自定义验证消息)
      • [31.6.4 自定义验证 UI](#31.6.4 自定义验证 UI)
      • [31.6.5 验证时机与最佳实践](#31.6.5 验证时机与最佳实践)
    • [31.7 实战应用](#31.7 实战应用)
      • [31.7.1 表单验证库](#31.7.1 表单验证库)
      • [31.7.2 多步表单](#31.7.2 多步表单)
    • 本章小结

上篇回顾第30章:事件机制 --- 我们学习了浏览器事件模型(捕获→目标→冒泡三个阶段)、事件委托与性能优化、阻止默认行为与停止传播、自定义事件(CustomEvent),以及鼠标事件、键盘事件、拖放事件、指针事件、滚动事件等各类浏览器事件的使用。

下篇预告第32章:页面生命周期与资源加载 --- 我们将学习页面生命周期事件(DOMContentLoaded、load、beforeunload、unload)、脚本加载(async 与 defer)、资源加载监控,以及实战首屏加载性能监控。


引言

表单是 Web 应用中用户与服务器交互的核心渠道------注册登录、搜索查询、数据录入、文件上传,几乎所有需要用户输入的场景都离不开表单。

在前一章中,我们学习了浏览器事件机制。本章将把事件机制应用到表单上------表单有自己独特的事件(changeinputsubmitfocusblur)、验证机制和提交流程。理解这些内容,是构建良好用户体验的基础。

javascript 复制代码
// 一个典型的表单处理流程
const form = document.getElementById('login-form');

// 监听表单提交
form.addEventListener('submit', (event) => {
  event.preventDefault(); // 阻止默认提交

  // 获取表单数据
  const formData = new FormData(form);
  const username = formData.get('username');
  const password = formData.get('password');

  // 验证
  if (!username || !password) {
    showError('请填写用户名和密码');
    return;
  }

  // 提交
  fetch('/api/login', {
    method: 'POST',
    body: formData
  }).then(response => {
    if (response.ok) {
      console.log('登录成功');
    }
  });
});

#mermaid-svg-2n5z37lTrXDtmbOt{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-2n5z37lTrXDtmbOt .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2n5z37lTrXDtmbOt .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2n5z37lTrXDtmbOt .error-icon{fill:#552222;}#mermaid-svg-2n5z37lTrXDtmbOt .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2n5z37lTrXDtmbOt .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2n5z37lTrXDtmbOt .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2n5z37lTrXDtmbOt .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2n5z37lTrXDtmbOt .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2n5z37lTrXDtmbOt .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2n5z37lTrXDtmbOt .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2n5z37lTrXDtmbOt .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2n5z37lTrXDtmbOt .marker.cross{stroke:#333333;}#mermaid-svg-2n5z37lTrXDtmbOt svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2n5z37lTrXDtmbOt p{margin:0;}#mermaid-svg-2n5z37lTrXDtmbOt .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-2n5z37lTrXDtmbOt .cluster-label text{fill:#333;}#mermaid-svg-2n5z37lTrXDtmbOt .cluster-label span{color:#333;}#mermaid-svg-2n5z37lTrXDtmbOt .cluster-label span p{background-color:transparent;}#mermaid-svg-2n5z37lTrXDtmbOt .label text,#mermaid-svg-2n5z37lTrXDtmbOt span{fill:#333;color:#333;}#mermaid-svg-2n5z37lTrXDtmbOt .node rect,#mermaid-svg-2n5z37lTrXDtmbOt .node circle,#mermaid-svg-2n5z37lTrXDtmbOt .node ellipse,#mermaid-svg-2n5z37lTrXDtmbOt .node polygon,#mermaid-svg-2n5z37lTrXDtmbOt .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-2n5z37lTrXDtmbOt .rough-node .label text,#mermaid-svg-2n5z37lTrXDtmbOt .node .label text,#mermaid-svg-2n5z37lTrXDtmbOt .image-shape .label,#mermaid-svg-2n5z37lTrXDtmbOt .icon-shape .label{text-anchor:middle;}#mermaid-svg-2n5z37lTrXDtmbOt .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-2n5z37lTrXDtmbOt .rough-node .label,#mermaid-svg-2n5z37lTrXDtmbOt .node .label,#mermaid-svg-2n5z37lTrXDtmbOt .image-shape .label,#mermaid-svg-2n5z37lTrXDtmbOt .icon-shape .label{text-align:center;}#mermaid-svg-2n5z37lTrXDtmbOt .node.clickable{cursor:pointer;}#mermaid-svg-2n5z37lTrXDtmbOt .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-2n5z37lTrXDtmbOt .arrowheadPath{fill:#333333;}#mermaid-svg-2n5z37lTrXDtmbOt .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-2n5z37lTrXDtmbOt .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-2n5z37lTrXDtmbOt .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2n5z37lTrXDtmbOt .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-2n5z37lTrXDtmbOt .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2n5z37lTrXDtmbOt .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-2n5z37lTrXDtmbOt .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-2n5z37lTrXDtmbOt .cluster text{fill:#333;}#mermaid-svg-2n5z37lTrXDtmbOt .cluster span{color:#333;}#mermaid-svg-2n5z37lTrXDtmbOt div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-2n5z37lTrXDtmbOt .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-2n5z37lTrXDtmbOt rect.text{fill:none;stroke-width:0;}#mermaid-svg-2n5z37lTrXDtmbOt .icon-shape,#mermaid-svg-2n5z37lTrXDtmbOt .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2n5z37lTrXDtmbOt .icon-shape p,#mermaid-svg-2n5z37lTrXDtmbOt .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-2n5z37lTrXDtmbOt .icon-shape .label rect,#mermaid-svg-2n5z37lTrXDtmbOt .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2n5z37lTrXDtmbOt .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-2n5z37lTrXDtmbOt .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-2n5z37lTrXDtmbOt :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 第31章:表单与控件
表单基础
表单元素
聚焦 focus/blur
表单事件
表单提交
表单验证
实战应用
form 元素 / form.elements / 反向引用
input / textarea / select / checkbox / radio
focus / blur / focusin / focusout / tabindex
change / input / 剪贴板事件
submit 事件 / FormData / fetch 提交
HTML5 验证属性 / Constraint Validation API
表单验证库 / 多步表单

本章目标

  • 理解 <form> 元素的属性与方法,掌握 form.elements 集合与反向引用 element.form
  • 熟练操作各类表单控件(input、textarea、select、checkbox、radio)
  • 掌握 focus/blur 事件与 focusin/focusout 的区别,理解 tabindex 聚焦顺序
  • 区分 change 与 input 事件的触发时机,掌握剪贴板事件
  • 掌握表单提交流程(submit 事件、FormData、fetch 提交)
  • 理解 HTML5 表单验证属性与 Constraint Validation API
  • 实战:封装表单验证库、实现多步表单

31.1 表单与表单元素基础

31.1.1 form 元素

<form> 是表单的容器元素,所有表单控件都应该放在 <form> 内部:

html 复制代码
<form id="myForm" action="/api/submit" method="POST" enctype="application/x-www-form-urlencoded">
  <input type="text" name="username">
  <button type="submit">提交</button>
</form>
属性 说明 常用值
action 提交地址(URL) /api/loginhttps://example.com/submit
method 提交方法 GETPOST
enctype 编码类型 application/x-www-form-urlencoded(默认)、multipart/form-data(文件上传)、text/plain
target 提交目标窗口 _self_blank
novalidate 禁用浏览器原生验证 布尔属性
autocomplete 自动填充 on(默认)、off
javascript 复制代码
const form = document.getElementById('myForm');

// 读写表单属性
console.log(form.action);   // 完整提交 URL
console.log(form.method);   // 'POST'
console.log(form.enctype);  // 'application/x-www-form-urlencoded'

// 修改属性
form.action = '/api/new-endpoint';
form.method = 'POST';

// 表单方法
form.submit();          // 直接提交(不触发 submit 事件!)
form.requestSubmit();   // 提交并触发 submit 事件(推荐)
form.reset();           // 重置表单到初始状态
form.checkValidity();   // 检查所有字段是否通过验证

// 事件
form.addEventListener('submit', handleSubmit);
form.addEventListener('reset', handleReset);

注意form.submit() 会直接提交表单,不会 触发 submit 事件,因此不会执行验证逻辑。如果需要先验证再提交,应使用 form.requestSubmit() 或点击提交按钮。

31.1.2 表单控件概览

#mermaid-svg-syFynqsT7aw7CW0w{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-syFynqsT7aw7CW0w .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-syFynqsT7aw7CW0w .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-syFynqsT7aw7CW0w .error-icon{fill:#552222;}#mermaid-svg-syFynqsT7aw7CW0w .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-syFynqsT7aw7CW0w .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-syFynqsT7aw7CW0w .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-syFynqsT7aw7CW0w .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-syFynqsT7aw7CW0w .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-syFynqsT7aw7CW0w .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-syFynqsT7aw7CW0w .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-syFynqsT7aw7CW0w .marker{fill:#333333;stroke:#333333;}#mermaid-svg-syFynqsT7aw7CW0w .marker.cross{stroke:#333333;}#mermaid-svg-syFynqsT7aw7CW0w svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-syFynqsT7aw7CW0w p{margin:0;}#mermaid-svg-syFynqsT7aw7CW0w .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-syFynqsT7aw7CW0w .cluster-label text{fill:#333;}#mermaid-svg-syFynqsT7aw7CW0w .cluster-label span{color:#333;}#mermaid-svg-syFynqsT7aw7CW0w .cluster-label span p{background-color:transparent;}#mermaid-svg-syFynqsT7aw7CW0w .label text,#mermaid-svg-syFynqsT7aw7CW0w span{fill:#333;color:#333;}#mermaid-svg-syFynqsT7aw7CW0w .node rect,#mermaid-svg-syFynqsT7aw7CW0w .node circle,#mermaid-svg-syFynqsT7aw7CW0w .node ellipse,#mermaid-svg-syFynqsT7aw7CW0w .node polygon,#mermaid-svg-syFynqsT7aw7CW0w .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-syFynqsT7aw7CW0w .rough-node .label text,#mermaid-svg-syFynqsT7aw7CW0w .node .label text,#mermaid-svg-syFynqsT7aw7CW0w .image-shape .label,#mermaid-svg-syFynqsT7aw7CW0w .icon-shape .label{text-anchor:middle;}#mermaid-svg-syFynqsT7aw7CW0w .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-syFynqsT7aw7CW0w .rough-node .label,#mermaid-svg-syFynqsT7aw7CW0w .node .label,#mermaid-svg-syFynqsT7aw7CW0w .image-shape .label,#mermaid-svg-syFynqsT7aw7CW0w .icon-shape .label{text-align:center;}#mermaid-svg-syFynqsT7aw7CW0w .node.clickable{cursor:pointer;}#mermaid-svg-syFynqsT7aw7CW0w .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-syFynqsT7aw7CW0w .arrowheadPath{fill:#333333;}#mermaid-svg-syFynqsT7aw7CW0w .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-syFynqsT7aw7CW0w .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-syFynqsT7aw7CW0w .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-syFynqsT7aw7CW0w .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-syFynqsT7aw7CW0w .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-syFynqsT7aw7CW0w .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-syFynqsT7aw7CW0w .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-syFynqsT7aw7CW0w .cluster text{fill:#333;}#mermaid-svg-syFynqsT7aw7CW0w .cluster span{color:#333;}#mermaid-svg-syFynqsT7aw7CW0w div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-syFynqsT7aw7CW0w .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-syFynqsT7aw7CW0w rect.text{fill:none;stroke-width:0;}#mermaid-svg-syFynqsT7aw7CW0w .icon-shape,#mermaid-svg-syFynqsT7aw7CW0w .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-syFynqsT7aw7CW0w .icon-shape p,#mermaid-svg-syFynqsT7aw7CW0w .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-syFynqsT7aw7CW0w .icon-shape .label rect,#mermaid-svg-syFynqsT7aw7CW0w .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-syFynqsT7aw7CW0w .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-syFynqsT7aw7CW0w .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-syFynqsT7aw7CW0w :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 表单控件
input(最通用)
textarea(多行文本)
select(下拉选择)
button(按钮)
output(计算结果输出)
fieldset / legend(分组)
text / password / email / number / date / color / range / file / checkbox / radio / hidden / submit / button / image

控件 标签 用途 常用属性
文本输入 <input type="text"> 单行文本 valueplaceholdermaxlength
密码输入 <input type="password"> 密码(掩码显示) 同上
多行文本 <textarea> 多行文本 rowscolsmaxlength
下拉选择 <select> + <option> 选择列表 multiplesize
复选框 <input type="checkbox"> 多选 checked
单选按钮 <input type="radio"> 单选(同 name 分组) checked
文件上传 <input type="file"> 文件选择 acceptmultiple
提交按钮 <button type="submit"> 提交表单 ---
隐藏字段 <input type="hidden"> 存储不可见数据 value

31.1.3 form.elements 与 elements 集合

form.elements 返回表单中所有控件的集合(HTMLFormControlsCollection),可以通过索引或 name 访问:

html 复制代码
<form id="myForm">
  <input type="text" name="username" value="Alice">
  <input type="email" name="email" value="alice@example.com">
  <input type="password" name="password">
  <select name="city">
    <option value="bj">北京</option>
    <option value="sh">上海</option>
  </select>
  <input type="checkbox" name="hobby" value="reading" checked>
  <input type="checkbox" name="hobby" value="music" checked>
  <input type="radio" name="gender" value="male" checked>
  <input type="radio" name="gender" value="female">
  <button type="submit">提交</button>
</form>
javascript 复制代码
const form = document.getElementById('myForm');

// 通过 name 访问(推荐)
console.log(form.elements.username.value); // 'Alice'
console.log(form.elements.email.value);    // 'alice@example.com'

// 通过索引访问
console.log(form.elements[0].name);        // 'username'

// form.elements 也可以直接通过 form 访问
console.log(form.username.value);          // 'Alice'(等价于 form.elements.username)

// 同名多元素(如 checkbox、radio)返回集合
console.log(form.elements.hobby);          // RadioNodeList(2) [input, input]
console.log(form.elements.hobby[0].value); // 'reading'
console.log(form.elements.hobby[1].value); // 'music'

// 遍历所有控件
for (const element of form.elements) {
  console.log(element.name, element.type, element.value);
}

// elements 的长度
console.log(form.elements.length); // 控件数量

注意 :当多个控件共用同一个 name(如一组 checkbox 或 radio),form.elements[name] 返回的是一个集合(RadioNodeList),而非单个元素。可以通过 form.elements.hobby.value 获取选中的值(radio 返回选中项的值,checkbox 返回第一个选中的值或空字符串)。

31.1.4 反向引用:element.form

每个表单控件都有 form 属性,指向它所属的 <form> 元素:

javascript 复制代码
const usernameInput = document.querySelector('input[name="username"]');

// 通过控件反向获取表单
console.log(usernameInput.form); // <form id="myForm">

// 实用:在任何控件的事件处理中获取表单
usernameInput.addEventListener('change', function() {
  const form = this.form;
  console.log('当前表单:', form.id);
  console.log('表单所有数据:', new FormData(form));
});

#mermaid-svg-tBlJxdGGMvpB0Qmi{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-tBlJxdGGMvpB0Qmi .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-tBlJxdGGMvpB0Qmi .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-tBlJxdGGMvpB0Qmi .error-icon{fill:#552222;}#mermaid-svg-tBlJxdGGMvpB0Qmi .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-tBlJxdGGMvpB0Qmi .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-tBlJxdGGMvpB0Qmi .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-tBlJxdGGMvpB0Qmi .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-tBlJxdGGMvpB0Qmi .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-tBlJxdGGMvpB0Qmi .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-tBlJxdGGMvpB0Qmi .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-tBlJxdGGMvpB0Qmi .marker{fill:#333333;stroke:#333333;}#mermaid-svg-tBlJxdGGMvpB0Qmi .marker.cross{stroke:#333333;}#mermaid-svg-tBlJxdGGMvpB0Qmi svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-tBlJxdGGMvpB0Qmi p{margin:0;}#mermaid-svg-tBlJxdGGMvpB0Qmi .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-tBlJxdGGMvpB0Qmi .cluster-label text{fill:#333;}#mermaid-svg-tBlJxdGGMvpB0Qmi .cluster-label span{color:#333;}#mermaid-svg-tBlJxdGGMvpB0Qmi .cluster-label span p{background-color:transparent;}#mermaid-svg-tBlJxdGGMvpB0Qmi .label text,#mermaid-svg-tBlJxdGGMvpB0Qmi span{fill:#333;color:#333;}#mermaid-svg-tBlJxdGGMvpB0Qmi .node rect,#mermaid-svg-tBlJxdGGMvpB0Qmi .node circle,#mermaid-svg-tBlJxdGGMvpB0Qmi .node ellipse,#mermaid-svg-tBlJxdGGMvpB0Qmi .node polygon,#mermaid-svg-tBlJxdGGMvpB0Qmi .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-tBlJxdGGMvpB0Qmi .rough-node .label text,#mermaid-svg-tBlJxdGGMvpB0Qmi .node .label text,#mermaid-svg-tBlJxdGGMvpB0Qmi .image-shape .label,#mermaid-svg-tBlJxdGGMvpB0Qmi .icon-shape .label{text-anchor:middle;}#mermaid-svg-tBlJxdGGMvpB0Qmi .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-tBlJxdGGMvpB0Qmi .rough-node .label,#mermaid-svg-tBlJxdGGMvpB0Qmi .node .label,#mermaid-svg-tBlJxdGGMvpB0Qmi .image-shape .label,#mermaid-svg-tBlJxdGGMvpB0Qmi .icon-shape .label{text-align:center;}#mermaid-svg-tBlJxdGGMvpB0Qmi .node.clickable{cursor:pointer;}#mermaid-svg-tBlJxdGGMvpB0Qmi .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-tBlJxdGGMvpB0Qmi .arrowheadPath{fill:#333333;}#mermaid-svg-tBlJxdGGMvpB0Qmi .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-tBlJxdGGMvpB0Qmi .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-tBlJxdGGMvpB0Qmi .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-tBlJxdGGMvpB0Qmi .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-tBlJxdGGMvpB0Qmi .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-tBlJxdGGMvpB0Qmi .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-tBlJxdGGMvpB0Qmi .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-tBlJxdGGMvpB0Qmi .cluster text{fill:#333;}#mermaid-svg-tBlJxdGGMvpB0Qmi .cluster span{color:#333;}#mermaid-svg-tBlJxdGGMvpB0Qmi div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-tBlJxdGGMvpB0Qmi .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-tBlJxdGGMvpB0Qmi rect.text{fill:none;stroke-width:0;}#mermaid-svg-tBlJxdGGMvpB0Qmi .icon-shape,#mermaid-svg-tBlJxdGGMvpB0Qmi .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-tBlJxdGGMvpB0Qmi .icon-shape p,#mermaid-svg-tBlJxdGGMvpB0Qmi .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-tBlJxdGGMvpB0Qmi .icon-shape .label rect,#mermaid-svg-tBlJxdGGMvpB0Qmi .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-tBlJxdGGMvpB0Qmi .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-tBlJxdGGMvpB0Qmi .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-tBlJxdGGMvpB0Qmi :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} form.elements
input.form
form
input

注意<input type="image"> 也有 form 属性,但它的行为类似提交按钮。<button> 如果在 <form> 内部,也会自动关联到表单。

31.1.5 fieldset 与 legend

<fieldset> 用于将表单控件分组,<legend> 为分组提供标题:

html 复制代码
<form id="myForm">
  <fieldset>
    <legend>个人信息</legend>
    <input type="text" name="name" placeholder="姓名">
    <input type="email" name="email" placeholder="邮箱">
  </fieldset>

  <fieldset disabled>
    <legend>高级设置(已禁用)</legend>
    <input type="text" name="apiKey" placeholder="API Key">
  </fieldset>
</form>
javascript 复制代码
// fieldset 的 disabled 属性会禁用内部所有控件
const fieldset = document.querySelector('fieldset');
console.log(fieldset.disabled); // false

// 动态启用/禁用一组控件
function toggleSection(fieldset, enabled) {
  fieldset.disabled = !enabled;
}

// 获取 fieldset 内的控件
const personalFields = document.querySelectorAll('fieldset:nth-child(1) input');

实用<fieldset disabled> 可以一次性禁用内部所有控件,无需逐个设置 disabled 属性。


31.2 表单元素详解

31.2.1 input 元素

<input> 是最通用的表单控件,通过 type 属性指定不同类型:

javascript 复制代码
const input = document.querySelector('input[name="username"]');

// 基本属性
console.log(input.type);         // 'text'
console.log(input.name);          // 'username'
console.log(input.value);         // 当前值
console.log(input.defaultValue);  // 初始值(HTML 中 value 属性的值)
console.log(input.placeholder);   // 占位符文本
console.log(input.disabled);      // 是否禁用
console.log(input.readOnly);      // 是否只读
console.log(input.required);      // 是否必填
console.log(input.maxLength);     // 最大长度

// 设置值
input.value = '新值';

// 重置为初始值
input.value = input.defaultValue;

// 聚焦与选中文本
input.focus();
input.select();              // 选中所有文本
input.setSelectionRange(0, 3); // 选中前 3 个字符
type 类型 说明 特有属性
text 单行文本(默认) maxlengthpattern
password 密码输入 同 text
email 邮箱(带验证) multiple
number 数字输入 minmaxstep
date / datetime-local 日期/日期时间 minmax
color 颜色选择器 ---
range 滑块 minmaxstep
file 文件选择 acceptmultiple
checkbox 复选框 checked
radio 单选按钮 checked
hidden 隐藏字段 ---
submit 提交按钮 ---
search 搜索框 ---
tel 电话号码 ---
url URL 输入 ---
javascript 复制代码
// number 类型
const ageInput = document.querySelector('input[type="number"]');
ageInput.value         // '25'(字符串!)
ageInput.valueAsNumber // 25(数字)
ageInput.stepUp()      // 增加一个 step
ageInput.stepDown()    // 减少一个 step

// 设置数字值
ageInput.valueAsNumber = 30;

// date 类型
const dateInput = document.querySelector('input[type="date"]');
dateInput.value           // '2024-01-15'(ISO 格式字符串)
dateInput.valueAsDate     // Date 对象
dateInput.valueAsNumber   // 时间戳
dateInput.min = '2024-01-01';
dateInput.max = '2024-12-31';

// file 类型
const fileInput = document.querySelector('input[type="file"]');
console.log(fileInput.files);      // FileList(类数组)
console.log(fileInput.files[0]);   // File 对象
console.log(fileInput.files[0].name);  // 文件名
console.log(fileInput.files[0].size);  // 文件大小(字节)
console.log(fileInput.files[0].type);  // MIME 类型

// checkbox
const checkbox = document.querySelector('input[type="checkbox"]');
console.log(checkbox.checked);  // true/false
checkbox.checked = true;         // 勾选
checkbox.checked = false;        // 取消勾选

// radio(同名分组)
const radios = document.querySelectorAll('input[name="gender"]');
radios.forEach(radio => {
  if (radio.checked) {
    console.log('选中的性别:', radio.value);
  }
});

31.2.2 textarea 元素

<textarea> 用于多行文本输入:

html 复制代码
<textarea name="message" rows="5" cols="40" maxlength="500"
          placeholder="请输入留言..."></textarea>
javascript 复制代码
const textarea = document.querySelector('textarea[name="message"]');

// 基本属性
console.log(textarea.value);       // 当前内容
console.log(textarea.defaultValue); // 初始内容
console.log(textarea.rows);        // 可见行数
console.log(textarea.cols);        // 可见列数
console.log(textarea.maxLength);   // 最大字符数

// 自动调整高度
function autoResize(textarea) {
  textarea.style.height = 'auto';
  textarea.style.height = textarea.scrollHeight + 'px';
}

textarea.addEventListener('input', () => autoResize(textarea));

// 实时字数统计
const counter = document.getElementById('char-count');
textarea.addEventListener('input', () => {
  const remaining = textarea.maxLength - textarea.value.length;
  counter.textContent = `剩余 ${remaining} 字`;
});

注意<textarea> 没有 value 属性(HTML 属性),初始内容写在标签内部。但在 JavaScript 中通过 .value 属性读写内容。

31.2.3 select 与 option

<select> 创建下拉选择列表:

html 复制代码
<select name="city" id="city-select">
  <option value="">请选择城市</option>
  <option value="bj">北京</option>
  <option value="sh" selected>上海</option>
  <option value="gz">广州</option>
  <option value="sz">深圳</option>
</select>

<!-- 分组 -->
<select name="country">
  <optgroup label="亚洲">
    <option value="cn">中国</option>
    <option value="jp">日本</option>
  </optgroup>
  <optgroup label="欧洲">
    <option value="uk">英国</option>
    <option value="fr">法国</option>
  </optgroup>
</select>

<!-- 多选 -->
<select name="languages" multiple size="4">
  <option value="js">JavaScript</option>
  <option value="py">Python</option>
  <option value="java">Java</option>
</select>
javascript 复制代码
const select = document.getElementById('city-select');

// 获取选中值
console.log(select.value);          // 'sh'(选中项的 value)

// 获取选中的 option 元素
console.log(select.selectedIndex);  // 2(选中项的索引)
console.log(select.selectedOptions); // HTMLCollection [option]
console.log(select.selectedOptions[0].text); // '上海'
console.log(select.selectedOptions[0].value); // 'sh'

// 遍历所有 option
const options = select.options; // HTMLOptionsCollection
for (const option of options) {
  console.log(option.value, option.text, option.selected);
}

// 设置选中值
select.value = 'gz'; // 选中广州

// 通过索引设置
select.selectedIndex = 3; // 选中第四项

// 动态添加 option
const newOption = new Option('杭州', 'hz'); // 创建 Option
select.add(newOption); // 添加到末尾

// 或手动创建
const opt = document.createElement('option');
opt.value = 'hz';
opt.textContent = '杭州';
select.appendChild(opt);

// 移除 option
select.remove(0); // 移除第一个

// 多选 select
const multiSelect = document.querySelector('select[multiple]');
const selectedValues = Array.from(multiSelect.selectedOptions)
  .map(option => option.value);
console.log('选中的语言:', selectedValues);
属性/方法 说明
select.value 选中项的 value
select.selectedIndex 选中项的索引(-1 表示无选中)
select.selectedOptions 选中的 option 集合
select.options 所有 option 集合
select.multiple 是否允许多选
select.add(option, before) 添加 option
select.remove(index) 移除指定索引的 option
option.selected 是否被选中
option.value 选项值
option.text 选项显示文本

31.2.4 checkbox 与 radio 的特殊处理

javascript 复制代码
// checkbox:获取所有选中的值
const checkboxes = document.querySelectorAll('input[name="hobby"]:checked');
const hobbies = Array.from(checkboxes).map(cb => cb.value);
console.log('选中的爱好:', hobbies);

// 全选 / 全不选
const selectAllBtn = document.getElementById('select-all');
const allCheckboxes = document.querySelectorAll('input[name="hobby"]');

selectAllBtn.addEventListener('change', function() {
  allCheckboxes.forEach(cb => {
    cb.checked = this.checked;
  });
});

// 反选
document.getElementById('invert').addEventListener('click', () => {
  allCheckboxes.forEach(cb => {
    cb.checked = !cb.checked;
  });
});

// radio:获取选中的值
const selectedRadio = document.querySelector('input[name="gender"]:checked');
if (selectedRadio) {
  console.log('选中的性别:', selectedRadio.value);
}

// 程序设置 radio 选中
document.querySelector('input[name="gender"][value="female"]').checked = true;

#mermaid-svg-9jcSG3rm2JNf2Dsv{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9jcSG3rm2JNf2Dsv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9jcSG3rm2JNf2Dsv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9jcSG3rm2JNf2Dsv .error-icon{fill:#552222;}#mermaid-svg-9jcSG3rm2JNf2Dsv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9jcSG3rm2JNf2Dsv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9jcSG3rm2JNf2Dsv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9jcSG3rm2JNf2Dsv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9jcSG3rm2JNf2Dsv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9jcSG3rm2JNf2Dsv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9jcSG3rm2JNf2Dsv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9jcSG3rm2JNf2Dsv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9jcSG3rm2JNf2Dsv .marker.cross{stroke:#333333;}#mermaid-svg-9jcSG3rm2JNf2Dsv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9jcSG3rm2JNf2Dsv p{margin:0;}#mermaid-svg-9jcSG3rm2JNf2Dsv .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-9jcSG3rm2JNf2Dsv .cluster-label text{fill:#333;}#mermaid-svg-9jcSG3rm2JNf2Dsv .cluster-label span{color:#333;}#mermaid-svg-9jcSG3rm2JNf2Dsv .cluster-label span p{background-color:transparent;}#mermaid-svg-9jcSG3rm2JNf2Dsv .label text,#mermaid-svg-9jcSG3rm2JNf2Dsv span{fill:#333;color:#333;}#mermaid-svg-9jcSG3rm2JNf2Dsv .node rect,#mermaid-svg-9jcSG3rm2JNf2Dsv .node circle,#mermaid-svg-9jcSG3rm2JNf2Dsv .node ellipse,#mermaid-svg-9jcSG3rm2JNf2Dsv .node polygon,#mermaid-svg-9jcSG3rm2JNf2Dsv .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-9jcSG3rm2JNf2Dsv .rough-node .label text,#mermaid-svg-9jcSG3rm2JNf2Dsv .node .label text,#mermaid-svg-9jcSG3rm2JNf2Dsv .image-shape .label,#mermaid-svg-9jcSG3rm2JNf2Dsv .icon-shape .label{text-anchor:middle;}#mermaid-svg-9jcSG3rm2JNf2Dsv .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-9jcSG3rm2JNf2Dsv .rough-node .label,#mermaid-svg-9jcSG3rm2JNf2Dsv .node .label,#mermaid-svg-9jcSG3rm2JNf2Dsv .image-shape .label,#mermaid-svg-9jcSG3rm2JNf2Dsv .icon-shape .label{text-align:center;}#mermaid-svg-9jcSG3rm2JNf2Dsv .node.clickable{cursor:pointer;}#mermaid-svg-9jcSG3rm2JNf2Dsv .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-9jcSG3rm2JNf2Dsv .arrowheadPath{fill:#333333;}#mermaid-svg-9jcSG3rm2JNf2Dsv .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-9jcSG3rm2JNf2Dsv .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-9jcSG3rm2JNf2Dsv .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9jcSG3rm2JNf2Dsv .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-9jcSG3rm2JNf2Dsv .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9jcSG3rm2JNf2Dsv .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-9jcSG3rm2JNf2Dsv .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-9jcSG3rm2JNf2Dsv .cluster text{fill:#333;}#mermaid-svg-9jcSG3rm2JNf2Dsv .cluster span{color:#333;}#mermaid-svg-9jcSG3rm2JNf2Dsv div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-9jcSG3rm2JNf2Dsv .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-9jcSG3rm2JNf2Dsv rect.text{fill:none;stroke-width:0;}#mermaid-svg-9jcSG3rm2JNf2Dsv .icon-shape,#mermaid-svg-9jcSG3rm2JNf2Dsv .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9jcSG3rm2JNf2Dsv .icon-shape p,#mermaid-svg-9jcSG3rm2JNf2Dsv .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-9jcSG3rm2JNf2Dsv .icon-shape .label rect,#mermaid-svg-9jcSG3rm2JNf2Dsv .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9jcSG3rm2JNf2Dsv .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-9jcSG3rm2JNf2Dsv .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-9jcSG3rm2JNf2Dsv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} checkbox
独立开关(单个)
多选组(同 name)
radio
单选组(同 name)
同组只能选一个
获取:querySelectorAll(':checked')
获取:querySelector(':checked')


31.3 聚焦:focus / blur

31.3.1 focus 与 blur 事件

当用户点击或用 Tab 键将焦点移到表单元素时,触发 focus 事件;当焦点离开时,触发 blur 事件:
#mermaid-svg-1hpApgLIdSdZO3aB{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1hpApgLIdSdZO3aB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1hpApgLIdSdZO3aB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1hpApgLIdSdZO3aB .error-icon{fill:#552222;}#mermaid-svg-1hpApgLIdSdZO3aB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1hpApgLIdSdZO3aB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1hpApgLIdSdZO3aB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1hpApgLIdSdZO3aB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1hpApgLIdSdZO3aB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1hpApgLIdSdZO3aB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1hpApgLIdSdZO3aB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1hpApgLIdSdZO3aB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1hpApgLIdSdZO3aB .marker.cross{stroke:#333333;}#mermaid-svg-1hpApgLIdSdZO3aB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1hpApgLIdSdZO3aB p{margin:0;}#mermaid-svg-1hpApgLIdSdZO3aB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-1hpApgLIdSdZO3aB .cluster-label text{fill:#333;}#mermaid-svg-1hpApgLIdSdZO3aB .cluster-label span{color:#333;}#mermaid-svg-1hpApgLIdSdZO3aB .cluster-label span p{background-color:transparent;}#mermaid-svg-1hpApgLIdSdZO3aB .label text,#mermaid-svg-1hpApgLIdSdZO3aB span{fill:#333;color:#333;}#mermaid-svg-1hpApgLIdSdZO3aB .node rect,#mermaid-svg-1hpApgLIdSdZO3aB .node circle,#mermaid-svg-1hpApgLIdSdZO3aB .node ellipse,#mermaid-svg-1hpApgLIdSdZO3aB .node polygon,#mermaid-svg-1hpApgLIdSdZO3aB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1hpApgLIdSdZO3aB .rough-node .label text,#mermaid-svg-1hpApgLIdSdZO3aB .node .label text,#mermaid-svg-1hpApgLIdSdZO3aB .image-shape .label,#mermaid-svg-1hpApgLIdSdZO3aB .icon-shape .label{text-anchor:middle;}#mermaid-svg-1hpApgLIdSdZO3aB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1hpApgLIdSdZO3aB .rough-node .label,#mermaid-svg-1hpApgLIdSdZO3aB .node .label,#mermaid-svg-1hpApgLIdSdZO3aB .image-shape .label,#mermaid-svg-1hpApgLIdSdZO3aB .icon-shape .label{text-align:center;}#mermaid-svg-1hpApgLIdSdZO3aB .node.clickable{cursor:pointer;}#mermaid-svg-1hpApgLIdSdZO3aB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1hpApgLIdSdZO3aB .arrowheadPath{fill:#333333;}#mermaid-svg-1hpApgLIdSdZO3aB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1hpApgLIdSdZO3aB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1hpApgLIdSdZO3aB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1hpApgLIdSdZO3aB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1hpApgLIdSdZO3aB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1hpApgLIdSdZO3aB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1hpApgLIdSdZO3aB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1hpApgLIdSdZO3aB .cluster text{fill:#333;}#mermaid-svg-1hpApgLIdSdZO3aB .cluster span{color:#333;}#mermaid-svg-1hpApgLIdSdZO3aB div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-1hpApgLIdSdZO3aB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1hpApgLIdSdZO3aB rect.text{fill:none;stroke-width:0;}#mermaid-svg-1hpApgLIdSdZO3aB .icon-shape,#mermaid-svg-1hpApgLIdSdZO3aB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1hpApgLIdSdZO3aB .icon-shape p,#mermaid-svg-1hpApgLIdSdZO3aB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1hpApgLIdSdZO3aB .icon-shape .label rect,#mermaid-svg-1hpApgLIdSdZO3aB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1hpApgLIdSdZO3aB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1hpApgLIdSdZO3aB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1hpApgLIdSdZO3aB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户点击/Tab
focus 事件
元素获得焦点
用户点击其他处/Tab
blur 事件
元素失去焦点

javascript 复制代码
const input = document.querySelector('input[name="username"]');

// focus:获得焦点时触发
input.addEventListener('focus', (event) => {
  console.log('输入框获得焦点');
  event.target.classList.add('focused');
});

// blur:失去焦点时触发
input.addEventListener('blur', (event) => {
  console.log('输入框失去焦点');
  event.target.classList.remove('focused');

  // 失焦时验证
  validateField(event.target);
});

重要focusblur 事件不冒泡 !如果需要在父元素上委托监听,必须使用 focusinfocusout

31.3.2 focusin / focusout(冒泡版本)

focusinfocusoutfocus / blur 的冒泡版本:

事件 是否冒泡 对应关系
focus ❌ 不冒泡 获得焦点
blur ❌ 不冒泡 失去焦点
focusin ✅ 冒泡 获得焦点(在 focus 之前触发)
focusout ✅ 冒泡 失去焦点(在 blur 之前触发)
javascript 复制代码
// ❌ focus/blur 不冒泡,无法在父元素委托
form.addEventListener('focus', (e) => {
  // 只会捕获 form 自身的 focus,子元素的 focus 不会冒泡到这里
});

// ✅ focusin/focusout 冒泡,可以在父元素委托
form.addEventListener('focusin', (event) => {
  console.log('获得焦点:', event.target.name);
  event.target.classList.add('focused');
});

form.addEventListener('focusout', (event) => {
  console.log('失去焦点:', event.target.name);
  event.target.classList.remove('focused');

  // 失焦时验证该字段
  validateField(event.target);
});

#mermaid-svg-ks8MMeXIEz2eWNnL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ks8MMeXIEz2eWNnL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ks8MMeXIEz2eWNnL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ks8MMeXIEz2eWNnL .error-icon{fill:#552222;}#mermaid-svg-ks8MMeXIEz2eWNnL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ks8MMeXIEz2eWNnL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ks8MMeXIEz2eWNnL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ks8MMeXIEz2eWNnL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ks8MMeXIEz2eWNnL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ks8MMeXIEz2eWNnL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ks8MMeXIEz2eWNnL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ks8MMeXIEz2eWNnL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ks8MMeXIEz2eWNnL .marker.cross{stroke:#333333;}#mermaid-svg-ks8MMeXIEz2eWNnL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ks8MMeXIEz2eWNnL p{margin:0;}#mermaid-svg-ks8MMeXIEz2eWNnL .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ks8MMeXIEz2eWNnL .cluster-label text{fill:#333;}#mermaid-svg-ks8MMeXIEz2eWNnL .cluster-label span{color:#333;}#mermaid-svg-ks8MMeXIEz2eWNnL .cluster-label span p{background-color:transparent;}#mermaid-svg-ks8MMeXIEz2eWNnL .label text,#mermaid-svg-ks8MMeXIEz2eWNnL span{fill:#333;color:#333;}#mermaid-svg-ks8MMeXIEz2eWNnL .node rect,#mermaid-svg-ks8MMeXIEz2eWNnL .node circle,#mermaid-svg-ks8MMeXIEz2eWNnL .node ellipse,#mermaid-svg-ks8MMeXIEz2eWNnL .node polygon,#mermaid-svg-ks8MMeXIEz2eWNnL .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ks8MMeXIEz2eWNnL .rough-node .label text,#mermaid-svg-ks8MMeXIEz2eWNnL .node .label text,#mermaid-svg-ks8MMeXIEz2eWNnL .image-shape .label,#mermaid-svg-ks8MMeXIEz2eWNnL .icon-shape .label{text-anchor:middle;}#mermaid-svg-ks8MMeXIEz2eWNnL .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ks8MMeXIEz2eWNnL .rough-node .label,#mermaid-svg-ks8MMeXIEz2eWNnL .node .label,#mermaid-svg-ks8MMeXIEz2eWNnL .image-shape .label,#mermaid-svg-ks8MMeXIEz2eWNnL .icon-shape .label{text-align:center;}#mermaid-svg-ks8MMeXIEz2eWNnL .node.clickable{cursor:pointer;}#mermaid-svg-ks8MMeXIEz2eWNnL .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ks8MMeXIEz2eWNnL .arrowheadPath{fill:#333333;}#mermaid-svg-ks8MMeXIEz2eWNnL .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ks8MMeXIEz2eWNnL .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ks8MMeXIEz2eWNnL .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ks8MMeXIEz2eWNnL .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ks8MMeXIEz2eWNnL .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ks8MMeXIEz2eWNnL .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ks8MMeXIEz2eWNnL .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ks8MMeXIEz2eWNnL .cluster text{fill:#333;}#mermaid-svg-ks8MMeXIEz2eWNnL .cluster span{color:#333;}#mermaid-svg-ks8MMeXIEz2eWNnL div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ks8MMeXIEz2eWNnL .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ks8MMeXIEz2eWNnL rect.text{fill:none;stroke-width:0;}#mermaid-svg-ks8MMeXIEz2eWNnL .icon-shape,#mermaid-svg-ks8MMeXIEz2eWNnL .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ks8MMeXIEz2eWNnL .icon-shape p,#mermaid-svg-ks8MMeXIEz2eWNnL .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ks8MMeXIEz2eWNnL .icon-shape .label rect,#mermaid-svg-ks8MMeXIEz2eWNnL .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ks8MMeXIEz2eWNnL .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ks8MMeXIEz2eWNnL .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ks8MMeXIEz2eWNnL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} focusin/focusout(冒泡)
form
input
focusin 触发
冒泡到 form

form 也能收到
focus/blur(不冒泡)
form
input
focus 触发

只在 input 上

最佳实践 :需要在父元素(如 <form><div>)上统一监听子元素的聚焦/失焦事件时,使用 focusin/focusout

31.3.3 tabindex 与聚焦顺序

默认情况下,只有表单元素(input、button、select、textarea)和带 href 的链接可以获取焦点。通过 tabindex 属性可以让任何元素可聚焦:

html 复制代码
<!-- tabindex="0":可聚焦,按文档顺序参与 Tab 导航 -->
<div tabindex="0" style="border:1px solid #ccc;padding:10px;">
  可聚焦的 div(Tab 顺序:文档顺序)
</div>

<!-- tabindex="-1":可聚焦,但不参与 Tab 导航(只能通过 JS focus() 聚焦) -->
<div tabindex="-1" id="skip-link">
  跳过 Tab 导航但可 JS 聚焦
</div>

<!-- tabindex="1":可聚焦,优先级高于默认(不推荐使用正数) -->
<div tabindex="1">高优先级(不推荐)</div>
tabindex 值 可聚焦 Tab 导航 推荐使用
不设置 仅表单元素和链接 文档顺序 ✅ 默认行为
0 ✅ 所有元素 文档顺序 ✅ 推荐用于自定义控件
-1 ✅ 所有元素 ❌ 不参与 ✅ 用于 JS 程序化聚焦
正数(1, 2, 3...) ✅ 所有元素 按数字排序 ❌ 不推荐(破坏文档顺序)
javascript 复制代码
// 获取所有可聚焦元素
const focusableElements = document.querySelectorAll(
  'a[href], button:not([disabled]), input:not([disabled]), ' +
  'select:not([disabled]), textarea:not([disabled]), ' +
  '[tabindex]:not([tabindex="-1"])'
);

// 程序化聚焦
document.getElementById('username').focus();

// 程序化失焦
document.getElementById('username').blur();

// 检查元素是否当前有焦点
const isActive = (document.activeElement === input);
console.log('当前聚焦元素:', document.activeElement);

// 实用:Modal 打开时聚焦第一个输入框
function openModal(modal) {
  modal.style.display = 'block';
  const firstInput = modal.querySelector('input, textarea, select');
  if (firstInput) firstInput.focus();
}

31.3.4 实用:焦点陷阱(Focus Trap)

在 Modal 对话框中,Tab 键应只在 Modal 内部循环,不应跳到背景元素:

javascript 复制代码
/**
 * 焦点陷阱:将 Tab 键限制在指定容器内
 */
class FocusTrap {
  constructor(container) {
    this.container = container;
    this._previouslyFocused = null;
    this._handler = this._handleKeyDown.bind(this);
  }

  activate() {
    this._previouslyFocused = document.activeElement;
    // 聚焦容器内第一个可聚焦元素
    const firstFocusable = this._getFocusableElements()[0];
    if (firstFocusable) {
      firstFocusable.focus();
    }
    document.addEventListener('keydown', this._handler);
  }

  deactivate() {
    document.removeEventListener('keydown', this._handler);
    // 恢复之前的焦点
    if (this._previouslyFocused) {
      this._previouslyFocused.focus();
    }
  }

  _getFocusableElements() {
    return Array.from(this.container.querySelectorAll(
      'a[href], button:not([disabled]), input:not([disabled]), ' +
      'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
    )).filter(el => el.offsetParent !== null); // 过滤不可见元素
  }

  _handleKeyDown(event) {
    if (event.key !== 'Tab') return;

    const focusable = this._getFocusableElements();
    if (focusable.length === 0) return;

    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    if (event.shiftKey) {
      // Shift + Tab:向后
      if (document.activeElement === first) {
        event.preventDefault();
        last.focus();
      }
    } else {
      // Tab:向前
      if (document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    }
  }
}

// 使用
const modal = document.getElementById('modal');
const trap = new FocusTrap(modal);

// 打开 Modal 时激活
function openModal() {
  modal.style.display = 'block';
  trap.activate();
}

// 关闭 Modal 时取消
function closeModal() {
  modal.style.display = 'none';
  trap.deactivate();
}

31.4 表单事件:change、input、cut、copy、paste

31.4.1 change 事件

change 事件在表单控件的值发生变化且失去焦点后触发:

javascript 复制代码
const input = document.querySelector('input[name="username"]');

input.addEventListener('change', (event) => {
  console.log('值已改变(失焦后触发):', event.target.value);
});

不同控件的 change 触发时机:

控件类型 change 触发时机
input[type="text"] / textarea 内容改变失去焦点后
input[type="checkbox"] / radio 选中状态改变时(立即触发)
select 选中项改变时(立即触发)
input[type="file"] 文件选择后(立即触发)
input[type="date"] / color / range 值改变时(立即触发或失焦后,取决于浏览器)
javascript 复制代码
// text 输入:change 在失焦后触发
const textInput = document.querySelector('input[type="text"]');
textInput.addEventListener('change', (e) => {
  console.log('文本改变并失焦:', e.target.value);
});

// checkbox/radio:change 立即触发
const checkbox = document.querySelector('input[type="checkbox"]');
checkbox.addEventListener('change', (e) => {
  console.log('复选框状态:', e.target.checked);
});

// select:change 立即触发
const select = document.querySelector('select');
select.addEventListener('change', (e) => {
  console.log('选中了:', e.target.value, e.target.selectedOptions[0].text);
});

// file:change 在选择文件后触发
const fileInput = document.querySelector('input[type="file"]');
fileInput.addEventListener('change', (e) => {
  const files = e.target.files;
  console.log(`选择了 ${files.length} 个文件`);
  for (const file of files) {
    console.log(`  ${file.name} (${file.size} bytes)`);
  }
});

31.4.2 input 事件

input 事件在每次值改变时立即触发(不需要等待失焦),是实时响应用户输入的首选事件:

javascript 复制代码
const input = document.querySelector('input[type="text"]');

// input 事件:每次按键、粘贴、剪切都触发
input.addEventListener('input', (event) => {
  console.log('当前值:', event.target.value);
});

// textarea 同理
const textarea = document.querySelector('textarea');
textarea.addEventListener('input', (event) => {
  // 实时字数统计
  const count = event.target.value.length;
  document.getElementById('char-count').textContent = `${count} 字`;
});

注意input 事件不适用于 checkboxradioselect(这些用 change 事件)。但对于 checkboxinput 事件在现代浏览器中也会触发,不过为了兼容性,推荐使用 change

31.4.3 change vs input 对比

对比项 change input
text/textarea 触发时机 值改变失焦后 每次值改变立即
checkbox/radio 立即触发 ---
select 立即触发 ---
触发频率 低(失焦后一次) 高(每次输入一次)
是否冒泡
典型用途 表单验证、最终确认 实时搜索、字数统计、实时预览
javascript 复制代码
// 实时搜索(input 事件)
const searchInput = document.getElementById('search');
searchInput.addEventListener('input', debounce((event) => {
  const keyword = event.target.value.trim();
  if (keyword) {
    searchAPI(keyword).then(results => renderResults(results));
  }
}, 300));

// 失焦验证(change 事件)
const emailInput = document.getElementById('email');
emailInput.addEventListener('change', (event) => {
  const email = event.target.value;
  if (!isValidEmail(email)) {
    showError(event.target, '邮箱格式不正确');
  }
});

#mermaid-svg-morLpuMv9mPYajat{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-morLpuMv9mPYajat .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-morLpuMv9mPYajat .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-morLpuMv9mPYajat .error-icon{fill:#552222;}#mermaid-svg-morLpuMv9mPYajat .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-morLpuMv9mPYajat .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-morLpuMv9mPYajat .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-morLpuMv9mPYajat .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-morLpuMv9mPYajat .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-morLpuMv9mPYajat .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-morLpuMv9mPYajat .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-morLpuMv9mPYajat .marker{fill:#333333;stroke:#333333;}#mermaid-svg-morLpuMv9mPYajat .marker.cross{stroke:#333333;}#mermaid-svg-morLpuMv9mPYajat svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-morLpuMv9mPYajat p{margin:0;}#mermaid-svg-morLpuMv9mPYajat .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-morLpuMv9mPYajat .cluster-label text{fill:#333;}#mermaid-svg-morLpuMv9mPYajat .cluster-label span{color:#333;}#mermaid-svg-morLpuMv9mPYajat .cluster-label span p{background-color:transparent;}#mermaid-svg-morLpuMv9mPYajat .label text,#mermaid-svg-morLpuMv9mPYajat span{fill:#333;color:#333;}#mermaid-svg-morLpuMv9mPYajat .node rect,#mermaid-svg-morLpuMv9mPYajat .node circle,#mermaid-svg-morLpuMv9mPYajat .node ellipse,#mermaid-svg-morLpuMv9mPYajat .node polygon,#mermaid-svg-morLpuMv9mPYajat .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-morLpuMv9mPYajat .rough-node .label text,#mermaid-svg-morLpuMv9mPYajat .node .label text,#mermaid-svg-morLpuMv9mPYajat .image-shape .label,#mermaid-svg-morLpuMv9mPYajat .icon-shape .label{text-anchor:middle;}#mermaid-svg-morLpuMv9mPYajat .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-morLpuMv9mPYajat .rough-node .label,#mermaid-svg-morLpuMv9mPYajat .node .label,#mermaid-svg-morLpuMv9mPYajat .image-shape .label,#mermaid-svg-morLpuMv9mPYajat .icon-shape .label{text-align:center;}#mermaid-svg-morLpuMv9mPYajat .node.clickable{cursor:pointer;}#mermaid-svg-morLpuMv9mPYajat .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-morLpuMv9mPYajat .arrowheadPath{fill:#333333;}#mermaid-svg-morLpuMv9mPYajat .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-morLpuMv9mPYajat .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-morLpuMv9mPYajat .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-morLpuMv9mPYajat .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-morLpuMv9mPYajat .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-morLpuMv9mPYajat .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-morLpuMv9mPYajat .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-morLpuMv9mPYajat .cluster text{fill:#333;}#mermaid-svg-morLpuMv9mPYajat .cluster span{color:#333;}#mermaid-svg-morLpuMv9mPYajat div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-morLpuMv9mPYajat .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-morLpuMv9mPYajat rect.text{fill:none;stroke-width:0;}#mermaid-svg-morLpuMv9mPYajat .icon-shape,#mermaid-svg-morLpuMv9mPYajat .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-morLpuMv9mPYajat .icon-shape p,#mermaid-svg-morLpuMv9mPYajat .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-morLpuMv9mPYajat .icon-shape .label rect,#mermaid-svg-morLpuMv9mPYajat .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-morLpuMv9mPYajat .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-morLpuMv9mPYajat .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-morLpuMv9mPYajat :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户输入 'hello'
按键 h
input 事件触发
按键 e
input 事件触发
按键 l, l, o
input 事件触发 x3
用户点击其他地方(失焦)
change 事件触发

31.4.4 剪贴板事件

剪贴板事件在用户复制、剪切、粘贴时触发:

事件 触发时机 可阻止默认行为
copy 复制时
cut 剪切时
paste 粘贴时
javascript 复制代码
const input = document.getElementById('myInput');

// 监听粘贴
input.addEventListener('paste', (event) => {
  console.log('粘贴内容:', event.clipboardData.getData('text'));
  // 可以检查粘贴内容,阻止不安全内容
  const pastedText = event.clipboardData.getData('text');
  if (pastedText.includes('<script>')) {
    event.preventDefault();
    console.warn('检测到不安全内容,已阻止粘贴');
  }
});

// 监听复制
input.addEventListener('copy', (event) => {
  console.log('复制了内容');
  // 可以修改复制的内容
  const selection = window.getSelection().toString();
  event.clipboardData.setData('text/plain', selection.toUpperCase());
  event.preventDefault(); // 阻止默认复制行为,使用自定义内容
});

// 监听剪切
input.addEventListener('cut', (event) => {
  console.log('剪切了内容');
});

// 实用:禁止粘贴(某些场景如需要手动输入)
document.getElementById('no-paste').addEventListener('paste', (event) => {
  event.preventDefault();
  console.log('禁止粘贴,请手动输入');
});

// 实用:粘贴图片
document.addEventListener('paste', (event) => {
  const items = event.clipboardData.items;
  for (const item of items) {
    if (item.type.startsWith('image/')) {
      const file = item.getAsFile();
      console.log('粘贴了图片:', file.name, file.size);
      // 显示或上传图片
      const reader = new FileReader();
      reader.onload = (e) => {
        const img = document.createElement('img');
        img.src = e.target.result;
        document.body.appendChild(img);
      };
      reader.readAsDataURL(file);
    }
  }
});

注意event.clipboardDatacopy/cut 事件中用于设置复制内容,在 paste 事件中用于读取粘贴内容。必须在事件处理函数中同步访问,异步访问会失败。

31.4.5 Clipboard API(异步剪贴板)

现代浏览器提供了异步的 Clipboard API,可以在事件之外读写剪贴板:

javascript 复制代码
// 写入剪贴板
async function copyToClipboard(text) {
  try {
    await navigator.clipboard.writeText(text);
    console.log('已复制到剪贴板');
  } catch (err) {
    console.error('复制失败:', err);
    // 降级方案
    fallbackCopy(text);
  }
}

// 读取剪贴板
async function readFromClipboard() {
  try {
    const text = await navigator.clipboard.readText();
    console.log('剪贴板内容:', text);
  } catch (err) {
    console.error('读取失败:', err);
  }
}

// 传统降级方案
function fallbackCopy(text) {
  const textarea = document.createElement('textarea');
  textarea.value = text;
  textarea.style.position = 'fixed';
  textarea.style.opacity = '0';
  document.body.appendChild(textarea);
  textarea.select();
  document.execCommand('copy');
  document.body.removeChild(textarea);
}

// 使用
document.getElementById('copy-btn').addEventListener('click', () => {
  copyToClipboard('要复制的文本');
});
方式 异步/同步 需要 HTTPS 优点 缺点
clipboardData(事件) 同步 兼容性好 只能在事件中用
navigator.clipboard 异步 ✅(或 localhost) 随时可用 需要 HTTPS 和权限
execCommand('copy') 同步 兼容旧浏览器 已废弃

31.5 表单提交

31.5.1 submit 事件

当用户点击提交按钮或在一个文本输入框中按 Enter 键时,表单会触发 submit 事件:

javascript 复制代码
const form = document.getElementById('myForm');

form.addEventListener('submit', (event) => {
  // 阻止默认提交行为(页面跳转/刷新)
  event.preventDefault();

  // 获取表单数据
  const formData = new FormData(form);
  console.log('用户名:', formData.get('username'));
  console.log('邮箱:', formData.get('email'));

  // 进行验证
  if (!validateForm(form)) {
    return;
  }

  // 提交数据
  submitForm(formData);
});

#mermaid-svg-2nTXnFUWZuIHxqEV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-2nTXnFUWZuIHxqEV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2nTXnFUWZuIHxqEV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2nTXnFUWZuIHxqEV .error-icon{fill:#552222;}#mermaid-svg-2nTXnFUWZuIHxqEV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2nTXnFUWZuIHxqEV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2nTXnFUWZuIHxqEV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2nTXnFUWZuIHxqEV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2nTXnFUWZuIHxqEV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2nTXnFUWZuIHxqEV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2nTXnFUWZuIHxqEV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2nTXnFUWZuIHxqEV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2nTXnFUWZuIHxqEV .marker.cross{stroke:#333333;}#mermaid-svg-2nTXnFUWZuIHxqEV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2nTXnFUWZuIHxqEV p{margin:0;}#mermaid-svg-2nTXnFUWZuIHxqEV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-2nTXnFUWZuIHxqEV .cluster-label text{fill:#333;}#mermaid-svg-2nTXnFUWZuIHxqEV .cluster-label span{color:#333;}#mermaid-svg-2nTXnFUWZuIHxqEV .cluster-label span p{background-color:transparent;}#mermaid-svg-2nTXnFUWZuIHxqEV .label text,#mermaid-svg-2nTXnFUWZuIHxqEV span{fill:#333;color:#333;}#mermaid-svg-2nTXnFUWZuIHxqEV .node rect,#mermaid-svg-2nTXnFUWZuIHxqEV .node circle,#mermaid-svg-2nTXnFUWZuIHxqEV .node ellipse,#mermaid-svg-2nTXnFUWZuIHxqEV .node polygon,#mermaid-svg-2nTXnFUWZuIHxqEV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-2nTXnFUWZuIHxqEV .rough-node .label text,#mermaid-svg-2nTXnFUWZuIHxqEV .node .label text,#mermaid-svg-2nTXnFUWZuIHxqEV .image-shape .label,#mermaid-svg-2nTXnFUWZuIHxqEV .icon-shape .label{text-anchor:middle;}#mermaid-svg-2nTXnFUWZuIHxqEV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-2nTXnFUWZuIHxqEV .rough-node .label,#mermaid-svg-2nTXnFUWZuIHxqEV .node .label,#mermaid-svg-2nTXnFUWZuIHxqEV .image-shape .label,#mermaid-svg-2nTXnFUWZuIHxqEV .icon-shape .label{text-align:center;}#mermaid-svg-2nTXnFUWZuIHxqEV .node.clickable{cursor:pointer;}#mermaid-svg-2nTXnFUWZuIHxqEV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-2nTXnFUWZuIHxqEV .arrowheadPath{fill:#333333;}#mermaid-svg-2nTXnFUWZuIHxqEV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-2nTXnFUWZuIHxqEV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-2nTXnFUWZuIHxqEV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2nTXnFUWZuIHxqEV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-2nTXnFUWZuIHxqEV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2nTXnFUWZuIHxqEV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-2nTXnFUWZuIHxqEV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-2nTXnFUWZuIHxqEV .cluster text{fill:#333;}#mermaid-svg-2nTXnFUWZuIHxqEV .cluster span{color:#333;}#mermaid-svg-2nTXnFUWZuIHxqEV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-2nTXnFUWZuIHxqEV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-2nTXnFUWZuIHxqEV rect.text{fill:none;stroke-width:0;}#mermaid-svg-2nTXnFUWZuIHxqEV .icon-shape,#mermaid-svg-2nTXnFUWZuIHxqEV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2nTXnFUWZuIHxqEV .icon-shape p,#mermaid-svg-2nTXnFUWZuIHxqEV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-2nTXnFUWZuIHxqEV .icon-shape .label rect,#mermaid-svg-2nTXnFUWZuIHxqEV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2nTXnFUWZuIHxqEV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-2nTXnFUWZuIHxqEV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-2nTXnFUWZuIHxqEV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是

用户点击 submit 按钮

或在 input 中按 Enter
触发 submit 事件
调用了

preventDefault?
不提交,执行自定义逻辑
浏览器默认提交

页面跳转/刷新

关键点submit 事件只在 <form> 元素上触发,不在提交按钮上触发。监听器必须绑定在 form 上。

31.5.2 提交方法

javascript 复制代码
const form = document.getElementById('myForm');

// 方法一:form.submit() ------ 直接提交,不触发 submit 事件
// ⚠️ 不会执行验证、不会触发 submit 监听器
form.submit();

// 方法二:form.requestSubmit() ------ 提交并触发 submit 事件(推荐)
// ✅ 会触发 submit 监听器,执行验证逻辑
form.requestSubmit();

// 方法三:模拟点击提交按钮
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.click(); // 等价于 requestSubmit()
方法 触发 submit 事件 执行验证 推荐
form.submit() ⚠️ 仅在特殊场景使用
form.requestSubmit() ✅ 推荐
submitBtn.click() ✅ 兼容性更好

注意requestSubmit() 是较新的 API(Chrome 76+),如果需要兼容旧浏览器,使用 submitBtn.click() 作为替代。

31.5.3 FormData 对象

FormData 是处理表单数据的利器,可以轻松获取表单中所有字段的值:

javascript 复制代码
const form = document.getElementById('myForm');

// 从表单创建 FormData
const formData = new FormData(form);

// 读取值
console.log(formData.get('username'));    // 单值
console.log(formData.getAll('hobby'));    // 多值(如 checkbox)
console.log(formData.has('email'));       // 是否存在

// 设置值
formData.set('username', 'Alice');
formData.append('hobby', 'reading');      // 追加值(多选场景)
formData.delete('password');              // 删除字段

// 遍历所有字段
for (const [key, value] of formData.entries()) {
  console.log(key, ':', value);
}

// 转为普通对象
const data = Object.fromEntries(formData.entries());
console.log(data);
// { username: 'Alice', email: 'alice@example.com', ... }

// 转为 URL 查询字符串
const queryString = new URLSearchParams(formData).toString();
console.log(queryString); // 'username=Alice&email=alice%40example.com'
javascript 复制代码
// FormData 也可以手动构建(不依赖表单)
const formData = new FormData();
formData.append('username', 'Alice');
formData.append('email', 'alice@example.com');
formData.append('age', 25); // 数字会自动转为字符串

// 添加文件
const fileInput = document.querySelector('input[type="file"]');
if (fileInput.files[0]) {
  formData.append('avatar', fileInput.files[0]); // File 对象
}

// 添加 Blob
const blob = new Blob(['Hello World'], { type: 'text/plain' });
formData.append('file', blob, 'hello.txt');
方法 说明
formData.get(name) 获取第一个匹配的值
formData.getAll(name) 获取所有匹配的值(数组)
formData.set(name, value) 设置值(覆盖已有)
formData.append(name, value) 追加值(不覆盖)
formData.delete(name) 删除字段
formData.has(name) 检查是否存在
formData.entries() 遍历所有 key, value
formData.keys() 遍历所有 key
formData.values() 遍历所有 value

31.5.4 通过 fetch 提交表单

javascript 复制代码
// 提交为 application/x-www-form-urlencoded(默认格式)
async function submitUrlEncoded(form) {
  const formData = new FormData(form);
  const params = new URLSearchParams(formData);

  const response = await fetch(form.action, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    body: params
  });

  return response.json();
}

// 提交为 multipart/form-data(文件上传)
async function submitMultipart(form) {
  const formData = new FormData(form);
  // 注意:不要手动设置 Content-Type,浏览器会自动设置 boundary

  const response = await fetch(form.action, {
    method: 'POST',
    body: formData // 直接传 FormData,浏览器自动处理
  });

  return response.json();
}

// 提交为 JSON
async function submitJson(form) {
  const formData = new FormData(form);
  const data = Object.fromEntries(formData.entries());

  const response = await fetch(form.action, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(data)
  });

  return response.json();
}

// 使用示例
form.addEventListener('submit', async (event) => {
  event.preventDefault();

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

  try {
    const result = await submitJson(form);
    console.log('提交成功:', result);
    alert('提交成功!');
  } catch (error) {
    console.error('提交失败:', error);
    alert('提交失败,请重试');
  } finally {
    submitBtn.disabled = false;
    submitBtn.textContent = '提交';
  }
});

#mermaid-svg-AyRrrO3BHXuYtYAp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-AyRrrO3BHXuYtYAp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-AyRrrO3BHXuYtYAp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-AyRrrO3BHXuYtYAp .error-icon{fill:#552222;}#mermaid-svg-AyRrrO3BHXuYtYAp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-AyRrrO3BHXuYtYAp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-AyRrrO3BHXuYtYAp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-AyRrrO3BHXuYtYAp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-AyRrrO3BHXuYtYAp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-AyRrrO3BHXuYtYAp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-AyRrrO3BHXuYtYAp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-AyRrrO3BHXuYtYAp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-AyRrrO3BHXuYtYAp .marker.cross{stroke:#333333;}#mermaid-svg-AyRrrO3BHXuYtYAp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-AyRrrO3BHXuYtYAp p{margin:0;}#mermaid-svg-AyRrrO3BHXuYtYAp .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-AyRrrO3BHXuYtYAp .cluster-label text{fill:#333;}#mermaid-svg-AyRrrO3BHXuYtYAp .cluster-label span{color:#333;}#mermaid-svg-AyRrrO3BHXuYtYAp .cluster-label span p{background-color:transparent;}#mermaid-svg-AyRrrO3BHXuYtYAp .label text,#mermaid-svg-AyRrrO3BHXuYtYAp span{fill:#333;color:#333;}#mermaid-svg-AyRrrO3BHXuYtYAp .node rect,#mermaid-svg-AyRrrO3BHXuYtYAp .node circle,#mermaid-svg-AyRrrO3BHXuYtYAp .node ellipse,#mermaid-svg-AyRrrO3BHXuYtYAp .node polygon,#mermaid-svg-AyRrrO3BHXuYtYAp .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-AyRrrO3BHXuYtYAp .rough-node .label text,#mermaid-svg-AyRrrO3BHXuYtYAp .node .label text,#mermaid-svg-AyRrrO3BHXuYtYAp .image-shape .label,#mermaid-svg-AyRrrO3BHXuYtYAp .icon-shape .label{text-anchor:middle;}#mermaid-svg-AyRrrO3BHXuYtYAp .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-AyRrrO3BHXuYtYAp .rough-node .label,#mermaid-svg-AyRrrO3BHXuYtYAp .node .label,#mermaid-svg-AyRrrO3BHXuYtYAp .image-shape .label,#mermaid-svg-AyRrrO3BHXuYtYAp .icon-shape .label{text-align:center;}#mermaid-svg-AyRrrO3BHXuYtYAp .node.clickable{cursor:pointer;}#mermaid-svg-AyRrrO3BHXuYtYAp .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-AyRrrO3BHXuYtYAp .arrowheadPath{fill:#333333;}#mermaid-svg-AyRrrO3BHXuYtYAp .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-AyRrrO3BHXuYtYAp .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-AyRrrO3BHXuYtYAp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AyRrrO3BHXuYtYAp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-AyRrrO3BHXuYtYAp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AyRrrO3BHXuYtYAp .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-AyRrrO3BHXuYtYAp .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-AyRrrO3BHXuYtYAp .cluster text{fill:#333;}#mermaid-svg-AyRrrO3BHXuYtYAp .cluster span{color:#333;}#mermaid-svg-AyRrrO3BHXuYtYAp div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-AyRrrO3BHXuYtYAp .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-AyRrrO3BHXuYtYAp rect.text{fill:none;stroke-width:0;}#mermaid-svg-AyRrrO3BHXuYtYAp .icon-shape,#mermaid-svg-AyRrrO3BHXuYtYAp .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AyRrrO3BHXuYtYAp .icon-shape p,#mermaid-svg-AyRrrO3BHXuYtYAp .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-AyRrrO3BHXuYtYAp .icon-shape .label rect,#mermaid-svg-AyRrrO3BHXuYtYAp .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AyRrrO3BHXuYtYAp .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-AyRrrO3BHXuYtYAp .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-AyRrrO3BHXuYtYAp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} URL 编码
FormData/文件
JSON
用户提交表单
preventDefault 阻止默认行为
创建 FormData
提交格式?
URLSearchParams + fetch
直接传 FormData 给 fetch
Object.fromEntries + JSON.stringify
fetch 请求
处理响应


31.6 表单验证

31.6.1 HTML5 验证属性

HTML5 内置了表单验证功能,通过属性声明验证规则:

html 复制代码
<form>
  <!-- required:必填 -->
  <input type="text" name="username" required>

  <!-- minlength / maxlength:长度限制 -->
  <input type="password" name="password" required minlength="8" maxlength="20">

  <!-- pattern:正则验证 -->
  <input type="text" name="phone" pattern="^1[3-9]\d{9}$" placeholder="手机号">

  <!-- type 验证:email、url、number 等 -->
  <input type="email" name="email" required>
  <input type="url" name="website">
  <input type="number" name="age" min="18" max="120" step="1">

  <!-- date 验证 -->
  <input type="date" name="birthday" min="1900-01-01" max="2024-12-31">

  <!-- novalidate:禁用表单原生验证 -->
  <form novalidate>...</form>
</form>
验证属性 适用于 说明
required 所有输入 必填
minlength text、password、textarea 最小字符数
maxlength text、password、textarea 最大字符数
min / max number、date、range 数值/日期范围
step number、date、range 步长
pattern text、password、tel 等 正则表达式
type="email" input 邮箱格式验证
type="url" input URL 格式验证
type="number" input 数字验证

31.6.2 Constraint Validation API

JavaScript 提供了 Constraint Validation API 来检查和自定义验证:

javascript 复制代码
const input = document.querySelector('input[name="email"]');

// 检查有效性
console.log(input.validity.valid);           // 是否有效
console.log(input.validity.valueMissing);     // 是否缺少必填值(required)
console.log(input.validity.typeMismatch);     // 类型不匹配(如 email 格式错误)
console.log(input.validity.patternMismatch);  // 正则不匹配
console.log(input.validity.tooShort);         // 太短(minlength)
console.log(input.validity.tooLong);          // 太长(maxlength)
console.log(input.validity.rangeUnderflow);   // 小于 min
console.log(input.validity.rangeOverflow);    // 大于 max
console.log(input.validity.stepMismatch);     // 步长不匹配
console.log(input.validity.badInput);         // 输入格式错误(如 number 输入了非数字)

// 检查整个表单
const form = document.getElementById('myForm');
console.log(form.checkValidity()); // 检查所有字段,返回是否全部有效

// 检查单个字段
console.log(input.checkValidity()); // 检查该字段,返回是否有效

// 手动报告验证错误
input.reportValidity(); // 如果无效,显示浏览器原生错误提示

// 设置自定义验证消息
input.setCustomValidity('邮箱已被注册'); // 设置后该字段无效

// 清除自定义验证消息(必须设为空字符串才有效)
input.setCustomValidity('');
javascript 复制代码
// validity 对象的完整属性
const input = document.querySelector('input');
const v = input.validity;

console.log({
  valid: v.valid,               // 总体是否有效(所有其他属性为 false 时为 true)
  valueMissing: v.valueMissing,  // required 但为空
  typeMismatch: v.typeMismatch,  // type 不匹配
  patternMismatch: v.patternMismatch, // pattern 不匹配
  tooShort: v.tooShort,          // < minlength
  tooLong: v.tooLong,            // > maxlength
  rangeUnderflow: v.rangeUnderflow, // < min
  rangeOverflow: v.rangeOverflow,   // > max
  stepMismatch: v.stepMismatch,     // step 不匹配
  badInput: v.badInput,            // 格式错误
  customError: v.customError       // 有自定义错误(setCustomValidity)
});

31.6.3 自定义验证消息

javascript 复制代码
const passwordInput = document.querySelector('input[name="password"]');

passwordInput.addEventListener('input', function() {
  const value = this.value;

  // 清除之前的自定义消息
  this.setCustomValidity('');

  // 自定义验证规则
  if (value && value.length < 8) {
    this.setCustomValidity('密码至少 8 个字符');
  } else if (value && !/[A-Z]/.test(value)) {
    this.setCustomValidity('密码必须包含至少一个大写字母');
  } else if (value && !/[0-9]/.test(value)) {
    this.setCustomValidity('密码必须包含至少一个数字');
  }
});

// 提交时统一验证
form.addEventListener('submit', function(event) {
  // 先清除所有自定义消息
  this.querySelectorAll('input, textarea, select').forEach(el => {
    el.setCustomValidity('');
  });

  // 逐个验证
  let isValid = true;
  this.querySelectorAll('input, textarea, select').forEach(el => {
    if (!el.checkValidity()) {
      isValid = false;
      el.reportValidity(); // 显示浏览器原生错误
    }
  });

  if (!isValid) {
    event.preventDefault();
  }
});

31.6.4 自定义验证 UI

浏览器原生的验证提示不够美观,可以禁用原生提示,自定义错误显示:

html 复制代码
<form id="myForm" novalidate>
  <div class="form-group">
    <label for="username">用户名</label>
    <input type="text" id="username" name="username" required minlength="3">
    <span class="error-message" id="username-error"></span>
  </div>

  <div class="form-group">
    <label for="email">邮箱</label>
    <input type="email" id="email" name="email" required>
    <span class="error-message" id="email-error"></span>
  </div>

  <button type="submit">注册</button>
</form>
css 复制代码
.form-group {
  margin-bottom: 16px;
}
.form-group input {
  display: block;
  width: 100%;
  padding: 8px 12px;
  border: 1px solid #ccc;
  border-radius: 4px;
}
.form-group input.invalid {
  border-color: #f44336;
}
.error-message {
  display: block;
  color: #f44336;
  font-size: 12px;
  min-height: 16px;
  margin-top: 4px;
}
javascript 复制代码
// 自定义验证消息映射
const errorMessages = {
  valueMissing: '此字段为必填项',
  typeMismatch: {
    email: '请输入有效的邮箱地址',
    url: '请输入有效的 URL'
  },
  patternMismatch: '格式不正确',
  tooShort: (input) => `至少需要 ${input.minLength} 个字符`,
  tooLong: (input) => `最多 ${input.maxLength} 个字符`,
  rangeUnderflow: (input) => `值不能小于 ${input.min}`,
  rangeOverflow: (input) => `值不能大于 ${input.max}`
};

function getErrorMessage(input) {
  const v = input.validity;
  if (v.valueMissing) return errorMessages.valueMissing;
  if (v.typeMismatch && errorMessages.typeMismatch[input.type]) {
    return errorMessages.typeMismatch[input.type];
  }
  if (v.patternMismatch) return errorMessages.patternMismatch;
  if (v.tooShort) return errorMessages.tooShort(input);
  if (v.tooLong) return errorMessages.tooLong(input);
  if (v.rangeUnderflow) return errorMessages.rangeUnderflow(input);
  if (v.rangeOverflow) return errorMessages.rangeOverflow(input);
  if (v.customError) return input.validationMessage;
  return '输入无效';
}

function showFieldError(input) {
  const errorEl = document.getElementById(input.id + '-error');
  if (errorEl) {
    errorEl.textContent = getErrorMessage(input);
  }
  input.classList.add('invalid');
}

function clearFieldError(input) {
  const errorEl = document.getElementById(input.id + '-error');
  if (errorEl) {
    errorEl.textContent = '';
  }
  input.classList.remove('invalid');
}

// 实时验证(input 事件清除错误,blur 事件验证)
document.querySelectorAll('#myForm input').forEach(input => {
  input.addEventListener('input', () => {
    if (input.classList.contains('invalid')) {
      clearFieldError(input);
    }
  });

  input.addEventListener('blur', () => {
    if (!input.checkValidity()) {
      showFieldError(input);
    }
  });
});

// 提交时验证所有字段
document.getElementById('myForm').addEventListener('submit', function(event) {
  let isValid = true;

  this.querySelectorAll('input, textarea, select').forEach(input => {
    if (!input.checkValidity()) {
      showFieldError(input);
      isValid = false;
    }
  });

  if (!isValid) {
    event.preventDefault();
    // 聚焦第一个无效字段
    const firstInvalid = this.querySelector('.invalid');
    if (firstInvalid) firstInvalid.focus();
  }
});

#mermaid-svg-3Sf0D2xNlQcbFXpz{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3Sf0D2xNlQcbFXpz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3Sf0D2xNlQcbFXpz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3Sf0D2xNlQcbFXpz .error-icon{fill:#552222;}#mermaid-svg-3Sf0D2xNlQcbFXpz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3Sf0D2xNlQcbFXpz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3Sf0D2xNlQcbFXpz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3Sf0D2xNlQcbFXpz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3Sf0D2xNlQcbFXpz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3Sf0D2xNlQcbFXpz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3Sf0D2xNlQcbFXpz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3Sf0D2xNlQcbFXpz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3Sf0D2xNlQcbFXpz .marker.cross{stroke:#333333;}#mermaid-svg-3Sf0D2xNlQcbFXpz svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3Sf0D2xNlQcbFXpz p{margin:0;}#mermaid-svg-3Sf0D2xNlQcbFXpz .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-3Sf0D2xNlQcbFXpz .cluster-label text{fill:#333;}#mermaid-svg-3Sf0D2xNlQcbFXpz .cluster-label span{color:#333;}#mermaid-svg-3Sf0D2xNlQcbFXpz .cluster-label span p{background-color:transparent;}#mermaid-svg-3Sf0D2xNlQcbFXpz .label text,#mermaid-svg-3Sf0D2xNlQcbFXpz span{fill:#333;color:#333;}#mermaid-svg-3Sf0D2xNlQcbFXpz .node rect,#mermaid-svg-3Sf0D2xNlQcbFXpz .node circle,#mermaid-svg-3Sf0D2xNlQcbFXpz .node ellipse,#mermaid-svg-3Sf0D2xNlQcbFXpz .node polygon,#mermaid-svg-3Sf0D2xNlQcbFXpz .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3Sf0D2xNlQcbFXpz .rough-node .label text,#mermaid-svg-3Sf0D2xNlQcbFXpz .node .label text,#mermaid-svg-3Sf0D2xNlQcbFXpz .image-shape .label,#mermaid-svg-3Sf0D2xNlQcbFXpz .icon-shape .label{text-anchor:middle;}#mermaid-svg-3Sf0D2xNlQcbFXpz .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-3Sf0D2xNlQcbFXpz .rough-node .label,#mermaid-svg-3Sf0D2xNlQcbFXpz .node .label,#mermaid-svg-3Sf0D2xNlQcbFXpz .image-shape .label,#mermaid-svg-3Sf0D2xNlQcbFXpz .icon-shape .label{text-align:center;}#mermaid-svg-3Sf0D2xNlQcbFXpz .node.clickable{cursor:pointer;}#mermaid-svg-3Sf0D2xNlQcbFXpz .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-3Sf0D2xNlQcbFXpz .arrowheadPath{fill:#333333;}#mermaid-svg-3Sf0D2xNlQcbFXpz .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-3Sf0D2xNlQcbFXpz .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-3Sf0D2xNlQcbFXpz .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3Sf0D2xNlQcbFXpz .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3Sf0D2xNlQcbFXpz .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3Sf0D2xNlQcbFXpz .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-3Sf0D2xNlQcbFXpz .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-3Sf0D2xNlQcbFXpz .cluster text{fill:#333;}#mermaid-svg-3Sf0D2xNlQcbFXpz .cluster span{color:#333;}#mermaid-svg-3Sf0D2xNlQcbFXpz div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-3Sf0D2xNlQcbFXpz .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3Sf0D2xNlQcbFXpz rect.text{fill:none;stroke-width:0;}#mermaid-svg-3Sf0D2xNlQcbFXpz .icon-shape,#mermaid-svg-3Sf0D2xNlQcbFXpz .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3Sf0D2xNlQcbFXpz .icon-shape p,#mermaid-svg-3Sf0D2xNlQcbFXpz .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-3Sf0D2xNlQcbFXpz .icon-shape .label rect,#mermaid-svg-3Sf0D2xNlQcbFXpz .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3Sf0D2xNlQcbFXpz .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-3Sf0D2xNlQcbFXpz .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-3Sf0D2xNlQcbFXpz :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是





用户输入
input 事件
字段已有错误?
清除错误提示
等待
blur 事件
checkValidity
有效?
显示错误
清除错误
用户修正
提交
验证所有字段
全部有效?
显示所有错误

聚焦第一个无效字段
提交表单

31.6.5 验证时机与最佳实践

验证时机 事件 优点 缺点
输入时 input 实时反馈 可能过于频繁,干扰用户
失焦时 blur / change 不干扰输入 反馈稍滞后
提交时 submit 最终保证 可能一次性暴露大量错误

最佳实践

  1. 初次输入时不验证(让用户先完成输入)
  2. 失焦时进行验证(及时反馈)
  3. 输入修正时清除错误提示(不要持续显示已修正的错误)
  4. 提交时做最终验证(确保数据完整有效)
  5. 聚焦第一个无效字段(引导用户修正)

31.7 实战应用

31.7.1 表单验证库

封装一个灵活的表单验证库,支持自定义规则、实时验证和错误展示:

javascript 复制代码
/**
 * 表单验证库
 *
 * 特性:
 * - 声明式规则配置
 * - 内置常用验证规则
 * - 自定义验证规则
 * - 实时验证(blur 触发 + input 清除)
 * - 自定义错误展示
 */
class FormValidator {
  /**
   * @param {HTMLFormElement} form - 表单元素
   * @param {Object} rules - 验证规则
   * @param {Object} options - 配置选项
   */
  constructor(form, rules, options = {}) {
    this.form = form;
    this.rules = rules; // { fieldName: [rule1, rule2, ...] }
    this.options = Object.assign({
      errorClass: 'error',
      errorMessageClass: 'error-message',
      validateOnChange: true,
      validateOnBlur: true,
      customMessages: {}
    }, options);

    this._builtInRules = {
      required: (value) => value.trim() !== '' || '此字段为必填项',
      email: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || '请输入有效的邮箱地址',
      url: (value) => {
        if (!value) return true;
        try {
          new URL(value);
          return true;
        } catch {
          return '请输入有效的 URL';
        }
      },
      minLength: (n) => (value) => value.length >= n || `至少需要 ${n} 个字符`,
      maxLength: (n) => (value) => value.length <= n || `最多 ${n} 个字符`,
      pattern: (regex, msg) => (value) => regex.test(value) || msg,
      phone: (value) => /^1[3-9]\d{9}$/.test(value) || '请输入有效的手机号',
      number: (value) => !isNaN(Number(value)) || '请输入数字',
      min: (n) => (value) => Number(value) >= n || `值不能小于 ${n}`,
      max: (n) => (value) => Number(value) <= n || `值不能大于 ${n}`,
      confirmed: (targetName) => (value) => {
        const target = this.form.querySelector(`[name="${targetName}"]`);
        return value === target.value || '两次输入不一致';
      }
    };

    this._init();
  }

  /**
   * 添加自定义验证规则
   */
  addRule(name, validator) {
    this._builtInRules[name] = validator;
  }

  /**
   * 初始化事件监听
   */
  _init() {
    // 禁用浏览器原生验证
    this.form.setAttribute('novalidate', '');

    // 为每个有规则的字段绑定事件
    Object.keys(this.rules).forEach(fieldName => {
      const input = this.form.querySelector(`[name="${fieldName}"]`);
      if (!input) return;

      if (this.options.validateOnBlur) {
        input.addEventListener('blur', () => {
          this._validateField(fieldName);
        });
      }

      if (this.options.validateOnChange) {
        input.addEventListener('input', () => {
          // 如果字段已有错误,实时清除
          if (this._hasError(fieldName)) {
            this._validateField(fieldName);
          }
        });
      }
    });

    // 表单提交验证
    this.form.addEventListener('submit', (event) => {
      if (!this.validateAll()) {
        event.preventDefault();
      }
    });
  }

  /**
   * 验证单个字段
   */
  _validateField(fieldName) {
    const input = this.form.querySelector(`[name="${fieldName}"]`);
    if (!input) return true;

    const value = this._getFieldValue(input);
    const rules = this.rules[fieldName];

    // 跳过非必填的空值
    const isRequired = rules.some(r => r.rule === 'required');
    if (!value && !isRequired) {
      this._clearError(fieldName);
      return true;
    }

    // 逐条验证
    for (const ruleConfig of rules) {
      const { rule, params = [], message } = ruleConfig;
      const validator = typeof rule === 'function'
        ? rule
        : this._builtInRules[rule];

      if (!validator) continue;

      const result = validator(value, input, this.form);
      const isValid = result === true;

      if (!isValid) {
        const errorMsg = message || (typeof result === 'string' ? result : '验证失败');
        this._showError(fieldName, errorMsg);
        return false;
      }
    }

    this._clearError(fieldName);
    return true;
  }

  /**
   * 获取字段值(支持 checkbox 多选)
   */
  _getFieldValue(input) {
    if (input.type === 'checkbox') {
      if (input.name) {
        const checkboxes = this.form.querySelectorAll(`[name="${input.name}"]:checked`);
        return Array.from(checkboxes).map(cb => cb.value);
      }
      return input.checked ? input.value : '';
    }
    if (input.type === 'radio') {
      const checked = this.form.querySelector(`[name="${input.name}"]:checked`);
      return checked ? checked.value : '';
    }
    if (input.type === 'select-multiple') {
      return Array.from(input.selectedOptions).map(opt => opt.value);
    }
    return input.value;
  }

  /**
   * 验证所有字段
   */
  validateAll() {
    let allValid = true;
    let firstInvalidField = null;

    Object.keys(this.rules).forEach(fieldName => {
      const isValid = this._validateField(fieldName);
      if (!isValid && !firstInvalidField) {
        firstInvalidField = this.form.querySelector(`[name="${fieldName}"]`);
        allValid = false;
      }
    });

    if (firstInvalidField) {
      firstInvalidField.focus();
    }

    return allValid;
  }

  /**
   * 显示错误
   */
  _showError(fieldName, message) {
    const input = this.form.querySelector(`[name="${fieldName}"]`);
    if (!input) return;

    input.classList.add(this.options.errorClass);

    // 查找或创建错误提示元素
    let errorEl = input.parentElement.querySelector(`.${this.options.errorMessageClass}`);
    if (!errorEl) {
      errorEl = document.createElement('span');
      errorEl.className = this.options.errorMessageClass;
      input.parentElement.appendChild(errorEl);
    }
    errorEl.textContent = message;
  }

  /**
   * 清除错误
   */
  _clearError(fieldName) {
    const input = this.form.querySelector(`[name="${fieldName}"]`);
    if (!input) return;

    input.classList.remove(this.options.errorClass);

    const errorEl = input.parentElement.querySelector(`.${this.options.errorMessageClass}`);
    if (errorEl) {
      errorEl.textContent = '';
    }
  }

  _hasError(fieldName) {
    const input = this.form.querySelector(`[name="${fieldName}"]`);
    return input && input.classList.contains(this.options.errorClass);
  }
}

// ========== 使用示例 ==========
const form = document.getElementById('register-form');

const validator = new FormValidator(form, {
  username: [
    { rule: 'required', message: '请输入用户名' },
    { rule: 'minLength', params: [3], message: '用户名至少 3 个字符' },
    { rule: 'maxLength', params: [20], message: '用户名最多 20 个字符' },
    { rule: 'pattern', params: [/^[a-zA-Z0-9_]+$/], message: '用户名只能包含字母、数字和下划线' }
  ],
  email: [
    { rule: 'required', message: '请输入邮箱' },
    { rule: 'email' }
  ],
  phone: [
    { rule: 'required', message: '请输入手机号' },
    { rule: 'phone' }
  ],
  password: [
    { rule: 'required', message: '请输入密码' },
    { rule: 'minLength', params: [8], message: '密码至少 8 个字符' },
    { rule: 'pattern', params: [/(?=.*[A-Z])(?=.*[0-9])/], message: '密码必须包含大写字母和数字' }
  ],
  confirmPassword: [
    { rule: 'required', message: '请确认密码' },
    { rule: 'confirmed', params: ['password'], message: '两次密码输入不一致' }
  ],
  agree: [
    { rule: 'required', message: '请同意用户协议' }
  ]
});

// 添加自定义规则
validator.addRule('asyncCheck', async (value) => {
  const response = await fetch(`/api/check-username?name=${value}`);
  const data = await response.json();
  return data.available || '用户名已被占用';
});

31.7.2 多步表单

实现一个多步骤表单(如注册向导),支持前进/后退、步骤验证、进度条显示:

html 复制代码
<div id="multi-step-form" class="multi-step-container">
  <!-- 进度指示器 -->
  <div class="step-progress">
    <div class="step active" data-step="1">
      <span class="step-number">1</span>
      <span class="step-label">基本信息</span>
    </div>
    <div class="step" data-step="2">
      <span class="step-number">2</span>
      <span class="step-label">联系方式</span>
    </div>
    <div class="step" data-step="3">
      <span class="step-number">3</span>
      <span class="step-label">确认信息</span>
    </div>
  </div>

  <!-- 步骤内容 -->
  <form id="wizard-form">
    <!-- 步骤1:基本信息 -->
    <div class="step-content active" data-step="1">
      <h3>基本信息</h3>
      <div class="form-group">
        <label>用户名</label>
        <input type="text" name="username" required minlength="3">
      </div>
      <div class="form-group">
        <label>密码</label>
        <input type="password" name="password" required minlength="8">
      </div>
    </div>

    <!-- 步骤2:联系方式 -->
    <div class="step-content" data-step="2">
      <h3>联系方式</h3>
      <div class="form-group">
        <label>邮箱</label>
        <input type="email" name="email" required>
      </div>
      <div class="form-group">
        <label>手机号</label>
        <input type="tel" name="phone" required>
      </div>
      <div class="form-group">
        <label>城市</label>
        <select name="city" required>
          <option value="">请选择</option>
          <option value="bj">北京</option>
          <option value="sh">上海</option>
        </select>
      </div>
    </div>

    <!-- 步骤3:确认信息 -->
    <div class="step-content" data-step="3">
      <h3>确认信息</h3>
      <div id="summary"></div>
    </div>

    <!-- 导航按钮 -->
    <div class="step-nav">
      <button type="button" class="btn-prev" disabled>上一步</button>
      <button type="button" class="btn-next">下一步</button>
      <button type="submit" class="btn-submit" style="display:none;">提交</button>
    </div>
  </form>
</div>
css 复制代码
.step-progress {
  display: flex;
  justify-content: space-between;
  margin-bottom: 30px;
  position: relative;
}
.step-progress::before {
  content: '';
  position: absolute;
  top: 15px;
  left: 0;
  right: 0;
  height: 2px;
  background: #e0e0e0;
  z-index: 0;
}
.step {
  display: flex;
  flex-direction: column;
  align-items: center;
  position: relative;
  z-index: 1;
}
.step-number {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: #e0e0e0;
  color: #999;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: bold;
  transition: all 0.3s;
}
.step.active .step-number {
  background: #42b883;
  color: white;
}
.step.completed .step-number {
  background: #42b883;
  color: white;
}
.step.completed .step-number::after {
  content: '✓';
}
.step-label {
  margin-top: 8px;
  font-size: 12px;
  color: #666;
}
.step-content {
  display: none;
}
.step-content.active {
  display: block;
}
javascript 复制代码
/**
 * 多步表单向导
 */
class MultiStepForm {
  constructor(container, options = {}) {
    this.container = container;
    this.form = container.querySelector('form');
    this.steps = Array.from(container.querySelectorAll('.step-content'));
    this.progressSteps = Array.from(container.querySelectorAll('.step-progress .step'));
    this.btnPrev = container.querySelector('.btn-prev');
    this.btnNext = container.querySelector('.btn-next');
    this.btnSubmit = container.querySelector('.btn-submit');
    this.currentStep = 0;
    this.totalSteps = this.steps.length;
    this.onComplete = options.onComplete || (() => {});

    this._init();
  }

  _init() {
    this.btnPrev.addEventListener('click', () => this.prev());
    this.btnNext.addEventListener('click', () => this.next());
    this.form.addEventListener('submit', (e) => {
      e.preventDefault();
      this._submit();
    });

    this._updateUI();
  }

  /**
   * 验证当前步骤的字段
   */
  _validateCurrentStep() {
    const currentContent = this.steps[this.currentStep];
    const fields = currentContent.querySelectorAll('input, select, textarea');
    let isValid = true;
    let firstInvalid = null;

    fields.forEach(field => {
      // 清除之前的自定义验证
      field.setCustomValidity('');

      if (!field.checkValidity()) {
        isValid = false;
        if (!firstInvalid) firstInvalid = field;
        field.reportValidity();
      }
    });

    if (firstInvalid) {
      firstInvalid.focus();
    }

    return isValid;
  }

  /**
   * 下一步
   */
  next() {
    if (!this._validateCurrentStep()) return;

    if (this.currentStep < this.totalSteps - 1) {
      this.currentStep++;
      this._updateUI();

      // 如果是最后一步,显示摘要
      if (this.currentStep === this.totalSteps - 1) {
        this._showSummary();
      }
    }
  }

  /**
   * 上一步
   */
  prev() {
    if (this.currentStep > 0) {
      this.currentStep--;
      this._updateUI();
    }
  }

  /**
   * 更新 UI
   */
  _updateUI() {
    // 更新步骤内容显示
    this.steps.forEach((step, index) => {
      step.classList.toggle('active', index === this.currentStep);
    });

    // 更新进度指示器
    this.progressSteps.forEach((step, index) => {
      step.classList.remove('active', 'completed');
      if (index < this.currentStep) {
        step.classList.add('completed');
      } else if (index === this.currentStep) {
        step.classList.add('active');
      }
    });

    // 更新按钮
    this.btnPrev.disabled = this.currentStep === 0;
    const isLastStep = this.currentStep === this.totalSteps - 1;
    this.btnNext.style.display = isLastStep ? 'none' : '';
    this.btnSubmit.style.display = isLastStep ? '' : 'none';
  }

  /**
   * 显示信息摘要
   */
  _showSummary() {
    const formData = new FormData(this.form);
    const summary = document.getElementById('summary');
    const labels = {
      username: '用户名',
      password: '密码',
      email: '邮箱',
      phone: '手机号',
      city: '城市'
    };

    let html = '<dl style="display:grid;grid-template-columns:auto 1fr;gap:8px 16px;">';
    for (const [key, value] of formData.entries()) {
      if (key === 'password') {
        html += `<dt>${labels[key] || key}:</dt><dd>******</dd>`;
      } else {
        html += `<dt>${labels[key] || key}:</dt><dd>${value}</dd>`;
      }
    }
    html += '</dl>';
    summary.innerHTML = html;
  }

  /**
   * 提交表单
   */
  _submit() {
    // 最终验证所有步骤
    for (let i = 0; i < this.totalSteps; i++) {
      this.currentStep = i;
      if (!this._validateCurrentStep()) {
        this._updateUI();
        return;
      }
    }

    // 恢复到最后一步
    this.currentStep = this.totalSteps - 1;
    this._updateUI();

    // 获取数据
    const formData = new FormData(this.form);
    this.onComplete(formData);
  }

  /**
   * 获取表单数据
   */
  getData() {
    return Object.fromEntries(new FormData(this.form).entries());
  }
}

// 使用
const wizard = new MultiStepForm(
  document.getElementById('multi-step-form'),
  {
    onComplete: async (formData) => {
      const response = await fetch('/api/register', {
        method: 'POST',
        body: formData
      });
      if (response.ok) {
        alert('注册成功!');
      }
    }
  }
);

#mermaid-svg-vk9ek3IGOCYI8EZ7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vk9ek3IGOCYI8EZ7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .error-icon{fill:#552222;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .marker.cross{stroke:#333333;}#mermaid-svg-vk9ek3IGOCYI8EZ7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vk9ek3IGOCYI8EZ7 p{margin:0;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .cluster-label text{fill:#333;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .cluster-label span{color:#333;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .cluster-label span p{background-color:transparent;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .label text,#mermaid-svg-vk9ek3IGOCYI8EZ7 span{fill:#333;color:#333;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .node rect,#mermaid-svg-vk9ek3IGOCYI8EZ7 .node circle,#mermaid-svg-vk9ek3IGOCYI8EZ7 .node ellipse,#mermaid-svg-vk9ek3IGOCYI8EZ7 .node polygon,#mermaid-svg-vk9ek3IGOCYI8EZ7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .rough-node .label text,#mermaid-svg-vk9ek3IGOCYI8EZ7 .node .label text,#mermaid-svg-vk9ek3IGOCYI8EZ7 .image-shape .label,#mermaid-svg-vk9ek3IGOCYI8EZ7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .rough-node .label,#mermaid-svg-vk9ek3IGOCYI8EZ7 .node .label,#mermaid-svg-vk9ek3IGOCYI8EZ7 .image-shape .label,#mermaid-svg-vk9ek3IGOCYI8EZ7 .icon-shape .label{text-align:center;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .node.clickable{cursor:pointer;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .arrowheadPath{fill:#333333;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vk9ek3IGOCYI8EZ7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vk9ek3IGOCYI8EZ7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vk9ek3IGOCYI8EZ7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .cluster text{fill:#333;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .cluster span{color:#333;}#mermaid-svg-vk9ek3IGOCYI8EZ7 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vk9ek3IGOCYI8EZ7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .icon-shape,#mermaid-svg-vk9ek3IGOCYI8EZ7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .icon-shape p,#mermaid-svg-vk9ek3IGOCYI8EZ7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .icon-shape .label rect,#mermaid-svg-vk9ek3IGOCYI8EZ7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vk9ek3IGOCYI8EZ7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vk9ek3IGOCYI8EZ7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vk9ek3IGOCYI8EZ7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否





步骤1:基本信息
验证通过?
显示错误,停留在步骤1
步骤2:联系方式
验证通过?
显示错误,停留在步骤2
步骤3:确认信息
显示摘要
点击提交
验证全部步骤?
跳转到无效步骤
提交数据
完成


本章小结

知识点 要点
form 元素 action/method/enctype 属性,submit()/requestSubmit()/reset() 方法
form.elements 通过 name 或索引访问控件,同 name 多元素返回集合
element.form 控件反向引用所属表单
fieldset disabled 可禁用整组控件
input 类型 text、password、email、number、date、color、range、file、checkbox、radio、hidden 等
input 特殊属性 valueAsNumber(number)、valueAsDate(date)、files(file)、checked(checkbox/radio)
textarea 初始值在标签内部,JS 中用 .value 读写
select valueselectedIndexselectedOptionsoptionsadd()/remove() 操作选项
focus / blur 获得/失去焦点,不冒泡
focusin / focusout 获得/失去焦点的冒泡版本,适合事件委托
tabindex 0=可聚焦(文档顺序)、-1=可聚焦(不参与Tab)、正数=不推荐
document.activeElement 当前获得焦点的元素
change 事件 text/textarea 在失焦后触发,checkbox/radio/select 立即触发
input 事件 每次值改变立即触发(实时响应)
change vs input change=低频失焦验证,input=高频实时响应
剪贴板事件 copy/cut/paste,通过 clipboardData 读写数据
Clipboard API navigator.clipboard.writeText()/readText(),异步,需 HTTPS
submit 事件 <form> 上触发,preventDefault() 阻止默认提交
submit() vs requestSubmit() submit() 不触发事件/不验证,requestSubmit() 触发事件/验证(推荐)
FormData get/getAll/set/append/delete/has/entries,支持文件
fetch 提交 URL 编码用 URLSearchParams,文件用 FormData,JSON 用 JSON.stringify
HTML5 验证 required/minlength/maxlength/min/max/pattern/type
Constraint Validation validity 对象、checkValidity()reportValidity()setCustomValidity()
验证时机 初次不验证 → blur 验证 → input 清除错误 → submit 最终验证
焦点陷阱 Modal 中 Tab 键限制在容器内循环

一句话总结 :表单是用户与 Web 应用交互的核心渠道------<form> 通过 form.elements(按 name 访问)管理所有控件,控件通过 element.form 反向引用表单;focus/blur(不冒泡)和 focusin/focusout(冒泡)控制聚焦行为,tabindex 管理聚焦顺序;change 事件在 text/textarea 失焦后触发而 checkbox/select 立即触发,input 事件每次值改变立即触发(实时响应首选);表单提交通过监听 <form>submit 事件并 preventDefault(),用 FormData 收集数据,通过 fetch 以 URL 编码/FormData/JSON 格式提交;HTML5 内置验证属性(required/pattern/min/max 等)配合 Constraint Validation API(validity/checkValidity/setCustomValidity)实现表单验证,最佳验证时机是"初次不验证→blur 验证→input 清除→submit 最终验证"------掌握这些,就能构建出体验良好的表单交互。


下篇预告第32章:页面生命周期与资源加载 --- 我们将学习页面生命周期事件(DOMContentLoaded、load、beforeunload、unload)、脚本加载策略(async 与 defer 的区别)、资源加载事件(onload 与 onerror),以及实战首屏加载性能监控。


作者 :xhmico | CSDN 博客https://blog.csdn.net/xhmico

参考资源The Modern JavaScript Tutorial | MDN Web Docs | ECMAScript 规范

相关推荐
菜鸟~noob2331 小时前
【电子战】 第09篇:阵列信号处理——波束形成、MVDR、MUSIC【含matlab代码】
开发语言·matlab·信号处理
ocean21031 小时前
2025-2026年Python面试高频知识点洞察
开发语言·python·面试·python八股文
零依赖极客2 小时前
Day 6·1 ARMv8.2 dotprod——vdotq_s32 一条指令做 4 个点积
c语言·开发语言·人工智能·矩阵·arm·vllm
新时代牛马3 小时前
cyclictest 毛刺从哪来?从ftrace、latencytop、perf到IRQ/调度延迟定位
android·开发语言·python·kotlin
三8443 小时前
Java 模板注入(FreeMarker) · 02 · 模板引擎与 FreeMarker 语法
java·开发语言·web安全·freemarker
IvanCodes4 小时前
Python 基础语法(七):推导式与常见遍历写法
开发语言·python
暮雨哀尘4 小时前
Java 基础练习(一):创建类并调用对象
java·开发语言
Highcharts.js4 小时前
常见报错排雷指南2:导出失败的官方解法
javascript·react.js·ecmascript·highcharts·可视化图表·导出模块失败·导出服务
zhangzeyuaaa4 小时前
Python requests:raise_for_status() 与 4xx/5xx 异常层次详解
开发语言·python