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 应用中用户与服务器交互的核心渠道------注册登录、搜索查询、数据录入、文件上传,几乎所有需要用户输入的场景都离不开表单。
在前一章中,我们学习了浏览器事件机制。本章将把事件机制应用到表单上------表单有自己独特的事件(change、input、submit、focus、blur)、验证机制和提交流程。理解这些内容,是构建良好用户体验的基础。
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/login、https://example.com/submit |
method |
提交方法 | GET、POST |
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"> |
单行文本 | value、placeholder、maxlength |
| 密码输入 | <input type="password"> |
密码(掩码显示) | 同上 |
| 多行文本 | <textarea> |
多行文本 | rows、cols、maxlength |
| 下拉选择 | <select> + <option> |
选择列表 | multiple、size |
| 复选框 | <input type="checkbox"> |
多选 | checked |
| 单选按钮 | <input type="radio"> |
单选(同 name 分组) | checked |
| 文件上传 | <input type="file"> |
文件选择 | accept、multiple |
| 提交按钮 | <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 |
单行文本(默认) | maxlength、pattern |
password |
密码输入 | 同 text |
email |
邮箱(带验证) | multiple |
number |
数字输入 | min、max、step |
date / datetime-local |
日期/日期时间 | min、max |
color |
颜色选择器 | --- |
range |
滑块 | min、max、step |
file |
文件选择 | accept、multiple |
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);
});
重要 :
focus和blur事件不冒泡 !如果需要在父元素上委托监听,必须使用focusin和focusout。
31.3.2 focusin / focusout(冒泡版本)
focusin 和 focusout 是 focus / 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事件不适用于checkbox、radio和select(这些用change事件)。但对于checkbox,input事件在现代浏览器中也会触发,不过为了兼容性,推荐使用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.clipboardData在copy/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 |
最终保证 | 可能一次性暴露大量错误 |
最佳实践:
- 初次输入时不验证(让用户先完成输入)
- 失焦时进行验证(及时反馈)
- 输入修正时清除错误提示(不要持续显示已修正的错误)
- 提交时做最终验证(确保数据完整有效)
- 聚焦第一个无效字段(引导用户修正)
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 | value、selectedIndex、selectedOptions、options,add()/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 规范