从「能改页面」到「能写交互」------本文系统梳理 HTML 专有 DOM 接口、三种事件绑定方式、事件流机制,以及图片懒加载、无缝滚动、选项卡等生产级场景的实现思路。内容对齐 MDN EventTarget、HTMLFormElement、HTMLTableElement 等官方文档,并补充业界常见写法与归纳总结。
📌 图片资源说明
本文所有案例代码引用的图片路径均为 images/db01.jpg ~ images/db10.jpg。运行案例前需要:
- 确保 images 目录存在 --- 本目录下已有
images/文件夹 - 准备图片文件 --- 支持
db01.jpg~db10.jpg或同名的.svg文件(二者择一即可)
下文每个完整示例的 代码注释 均写在对应代码块之后,便于对照阅读。
快速替代方案:如无本地图片,可将代码中的图片路径替换为在线占位图服务:
html
<!-- 原代码 -->
<img src="images/db01.jpg" alt="">
<!-- 替换为 -->
<img src="https://via.placeholder.com/200x200?text=Image+1" alt="">
目录
- 一、知识脉络总览
- 二、名词解析
- [三、DOM 元素操作回顾(前置基础)](#三、DOM 元素操作回顾(前置基础))
- [3.1 属性操作](#3.1 属性操作)
- [3.2 样式操作](#3.2 样式操作)
- [3.3 内容与结构](#3.3 内容与结构)
- [3.4 尺寸与位置(只读为主)](#3.4 尺寸与位置(只读为主))
- [3.5 节点增删改](#3.5 节点增删改)
- [四、HTML DOM:表单、表格与 Image 工厂](#四、HTML DOM:表单、表格与 Image 工厂)
- [4.1 表单相关 API](#4.1 表单相关 API)
- [4.2 表格相关 API](#4.2 表格相关 API)
- [4.3
new Image()快速创建图片](#4.3 new Image() 快速创建图片)
- 五、事件机制深度解析
- [5.1 三种事件监听方式对比](#5.1 三种事件监听方式对比)
- [5.2 解除事件监听](#5.2 解除事件监听)
- [5.3 事件流:捕获、目标、冒泡](#5.3 事件流:捕获、目标、冒泡)
- [5.4 事件回调中的
this](#5.4 事件回调中的 this) - [5.5 事件对象:
target、preventDefault、stopPropagation](#5.5 事件对象:target、preventDefault、stopPropagation)- [5.5.1 理论:三个高频属性/方法](#5.5.1 理论:三个高频属性/方法)
- [5.5.2 完整示例:右键菜单与阻止冒泡](#5.5.2 完整示例:右键菜单与阻止冒泡)
- [5.5.3 现代 API:
addEventListener选项对象](#5.5.3 现代 API:addEventListener 选项对象)
- [六、本课涉及的 CSS 与业界应用](#六、本课涉及的 CSS 与业界应用)
- [6.1
box-sizing: border-box](#6.1 box-sizing: border-box) - [6.2
vertical-align](#6.2 vertical-align) - [6.3
table-layout: fixed+border-collapse: collapse](#6.3 table-layout: fixed + border-collapse: collapse) - [6.4 Flex 布局](#6.4 Flex 布局)
- [6.5
overflow: hidden](#6.5 overflow: hidden) - [6.6 状态类
.active](#6.6 状态类 .active) - [6.7
list-style: none](#6.7 list-style: none)
- [6.1
- 七、经典综合案例(完整可运行)
- [7.1 图片延迟加载(Lazy Load)](#7.1 图片延迟加载(Lazy Load))
- [7.2 无缝横向滚动](#7.2 无缝横向滚动)
- [7.3 随机点名器](#7.3 随机点名器)
- [7.4 选项卡(Tab)](#7.4 选项卡(Tab))
- [7.5 列表高亮切换(
this实战)](#7.5 列表高亮切换(this 实战)) - [7.6 手风琴菜单(排他进阶)](#7.6 手风琴菜单(排他进阶))
- [7.7 弹窗 Modal(点遮罩关闭)](#7.7 弹窗 Modal(点遮罩关闭))
- [7.8 实时字数统计](#7.8 实时字数统计)
- [7.9 事件委托------动态任务列表(完整版)](#7.9 事件委托——动态任务列表(完整版))
- 八、常用事件速查(进阶预习)
- [8.1 鼠标事件](#8.1 鼠标事件)
- [8.2 键盘事件](#8.2 键盘事件)
- [8.3 文档事件](#8.3 文档事件)
- [8.4 表单与图片事件](#8.4 表单与图片事件)
- 九、知识点归纳与对比表
- [9.1 事件绑定一句话总结](#9.1 事件绑定一句话总结)
- [9.2 HTML DOM vs 通用 DOM](#9.2 HTML DOM vs 通用 DOM)
- [9.3 案例技术栈对照](#9.3 案例技术栈对照)
- [9.4 事件委托模板(扩展)](#9.4 事件委托模板(扩展))
- 十、学习路线与最佳实践
- [十一、经典业务场景与 API 选型](#十一、经典业务场景与 API 选型)
- [11.1 理论串讲:从用户操作到页面更新](#11.1 理论串讲:从用户操作到页面更新)
- [11.2 扩展:现代懒加载(Intersection Observer)](#11.2 扩展:现代懒加载(Intersection Observer))
- [11.3 常见面试考点速记](#11.3 常见面试考点速记)
- 附录:与官方文档的对照阅读
一、知识脉络总览
本阶段核心可以概括为两条线:「HTML 元素专属 API」 与 「用户交互 → 事件」。
Day14 DOM
元素操作回顾
属性 data-*
样式 classList
尺寸位置 getBoundingClientRect
增删改查节点
HTML DOM
form elements submit reset
input focus blur select
select Option add remove
table insertRow deleteRow
new Image
事件
内联 onxxx 属性
DOM0 onxxx 赋值
DOM2 addEventListener
捕获与冒泡
this 指向绑定元素
综合案例
懒加载 scroll
无缝滚动 scrollLeft
选项卡 排他
点名器 定时器
手风琴 排他进阶
Modal 弹窗 stopPropagation
字数统计 input事件
任务列表 事件委托
用户操作
事件触发
捕获阶段 window→目标
目标阶段
冒泡阶段 目标→window
回调函数执行
修改 DOM / 样式 / 数据
二、名词解析
| 名词 | 含义 | 记忆要点 |
|---|---|---|
| DOM | Document Object Model,将 HTML 文档表示为可操作的对象树 | JS 通过 DOM API 读写页面 |
| HTML DOM | 针对特定 HTML 标签(如 form、table)扩展的专有接口 |
比通用 HTMLElement 多了业务方法 |
| 事件(Event) | 用户或浏览器触发的动作(点击、滚动、提交等) | 是交互的入口 |
| 事件监听(Event Listener) | 注册在元素上的回调函数,事件发生时被执行 | 三种注册方式行为不同 |
| 事件流(Event Flow) | 事件从根到目标再到根的传播路径 | 捕获 → 目标 → 冒泡 |
| 冒泡(Bubbling) | 事件从目标元素向上传递到祖先 | addEventListener 默认在冒泡阶段触发 |
| 捕获(Capturing) | 事件从根向下传递到目标 | 第三个参数为 true 时监听捕获阶段 |
| 目标元素(Target) | 实际接收事件的元素 | event.target 指向它 |
| 当前元素(Current Target) | 当前正在执行监听器的元素 | 回调里 this 通常等于它 |
| DOM0 级事件 | element.onclick = fn 形式 |
同名事件会被覆盖 |
| DOM2 级事件 | addEventListener / removeEventListener |
可绑定多个同名监听器 |
| 排他思想 | 一组元素中只允许一个处于「激活」状态 | Tab、手风琴、单选菜单 |
| 懒加载(Lazy Load) | 图片进入视口后再赋值 src |
减少首屏请求,提升性能 |
| Live Collection | 如 form.elements,DOM 变化时集合自动更新 |
动态表单要注意索引变化 |
data-* 自定义属性 |
data-src 等,通过 dataset 读取 |
常存真实图片地址 |
getBoundingClientRect() |
返回元素相对视口的位置与尺寸 | 懒加载、吸顶、拖拽的核心 API |
| 事件对象(Event) | 监听器第一个参数,描述事件详情 | 含 type、target、preventDefault 等 |
event.target |
实际触发事件的元素(用户点到的那个) | 事件委托时用来判断点击源 |
event.currentTarget |
当前正在执行监听器的元素 | 通常等于回调中的 this |
| 事件委托(Delegation) | 把监听绑在父元素,利用冒泡处理子元素 | 动态列表、表格增删行的标配 |
| 默认行为(Default Action) | 浏览器对事件的内置响应(如提交表单、打开链接) | 用 preventDefault() 可阻止 |
| IIFE | 立即执行函数 (function(){ ... })() |
案例常用,避免变量污染全局 |
三、DOM 元素操作回顾(前置基础)
在接触 HTML DOM 与事件之前,需要熟练以下通用能力------后续所有案例都建立在这些 API 之上。
3.1 属性操作
javascript
img.src = 'images/db01.jpg';
input.value = 'hello';
el.setAttribute('data-id', '100');
el.getAttribute('data-id');
el.dataset.id;
代码注释 :前两行为读写内置属性;setAttribute / getAttribute 操作自定义属性;data-id 对应 dataset.id。
3.2 样式操作
javascript
box.style.width = '200px';
getComputedStyle(box).width;
box.className = 'active';
box.classList.add('active');
box.classList.remove('active');
box.classList.toggle('active');
box.classList.contains('active');
代码注释 :style 读写行内样式;getComputedStyle 只读计算样式;classList 比 className 更适合增删切换类名。
3.3 内容与结构
| API | 可读 | 可写 | 说明 |
|---|---|---|---|
innerHTML |
✓ | ✓ | 含子标签的 HTML 字符串 |
outerHTML |
✓ | ✓ | 含自身的 HTML |
innerText |
✓ | ✓ | 渲染后的纯文本(受 CSS 影响) |
textContent |
✓ | ✓ | 所有文本节点(不受样式隐藏影响) |
理论 :需要插入 HTML 标签时用 innerHTML(注意 XSS,用户输入必须转义);只改纯文本优先 textContent,更安全且性能更好。innerText 会受 CSS(如 display:none)影响,取值与可见文本一致。
3.4 尺寸与位置(只读为主)
javascript
el.offsetWidth;
el.clientWidth;
el.scrollWidth;
el.getBoundingClientRect();
el.scrollTop = 100;
代码注释 :offset* 含 border;client* 含 padding 不含 border;scroll* 含溢出部分;getBoundingClientRect() 返回相对视口的位置与尺寸;scrollTop 可读写滚动距离。
尺寸 API 可视化对比
┌─────────────────── offsetWidth ──────────────────┐
│ border border │
│ ┌────────────── clientWidth ───────────────┐ │
│ │ padding padding │ │
│ │ ┌──────── 内容宽度 (content) ────────┐ │ │
│ │ │ │ │ │
│ │ └────────────────────────────────────┘ │ │
│ │ │ │
│ └───────────────────────────────────────────┘ │
└───────────────────────────────────────────────────┘
scrollWidth = max(clientWidth, 内容实际宽度含溢出)
| 属性 | 含 margin | 含 border | 含 padding | 含溢出内容 | 可写 |
|---|---|---|---|---|---|
offsetWidth |
✗ | ✓ | ✓ | ✗ | ✗ |
clientWidth |
✗ | ✗ | ✓ | ✗ | ✗ |
scrollWidth |
✗ | ✗ | ✓ | ✓ | ✗ |
scrollLeft |
--- | --- | --- | --- | ✓ |
减去border
加上溢出部分
返回
offsetWidth
含 border+padding+content
clientWidth
含 padding+content
scrollWidth
含所有内容
getBoundingClientRect
top/left/width/height
相对于视口
经典场景 :
offsetWidth算元素占位宽(含边框);clientWidth算可视内容区宽;scrollWidth > clientWidth说明内容溢出;getBoundingClientRect().top判断元素是否进入视口(懒加载核心)。
3.5 节点增删改
javascript
var div = document.createElement('div');
parent.appendChild(div);
parent.insertBefore(newNode, refNode);
parent.removeChild(child);
parent.replaceChild(newNode, oldNode);
var clone = node.cloneNode(true);
代码注释 :cloneNode(true) 为深克隆,连同子节点一并复制。
归纳:属性管数据,样式管外观,尺寸位置管布局计算,节点 API 管结构------事件则在用户动作发生时调用这些 API。
四、HTML DOM:表单、表格与 Image 工厂
部分 HTML 元素在 W3C 规范中拥有专属接口 ,可简化常见业务,无需手写大量 createElement。
4.1 表单相关 API
名词:HTMLFormElement
表示 <form> 元素,提供集合访问与程序化提交/重置。
| 属性/方法 | 类型 | 说明 |
|---|---|---|
length |
只读 | 表单控件数量 |
elements |
只读 Live 集合 | 所有控件(input、select、textarea、button) |
submit() |
方法 | 触发提交(可绕过 submit 按钮) |
reset() |
方法 | 重置为默认值 |
文本类控件 (input、textarea):
| 方法 | 说明 |
|---|---|
focus() |
获取焦点 |
blur() |
失去焦点 |
select() |
选中内部文字 |
<select> 元素:
| 属性/方法 | 说明 |
|---|---|
length |
选项个数 |
options |
所有 <option> 集合 |
selectedIndex |
当前选中项索引(从 0 开始) |
add(option, before) |
添加选项 |
remove(index) |
按索引删除 |
focus() / blur() |
焦点控制 |
快速创建 <option>:
javascript
var opt = new Option('北京', '1');
selectEl.add(opt);
代码注释 :new Option(显示文本, value) 等价于 <option value="1">北京</option>,配合 select.add() 动态添加选项。
完整示例:表单 API 综合演示
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTMLFormElement 演示</title>
<style>
/* box-sizing:电商/后台表单统一用 border-box,避免 padding 撑破宽度 */
input, textarea, select {
box-sizing: border-box;
width: 300px;
padding: 10px;
border: 1px solid #999;
}
/* vertical-align:标签与多行文本域顶部对齐 */
.label-top { vertical-align: top; }
</style>
</head>
<body>
<form id="contactForm">
<table>
<tr>
<td>邮箱:</td>
<td><input type="email" name="email"></td>
</tr>
<tr>
<td class="label-top">留言:</td>
<td><textarea rows="5"></textarea></td>
</tr>
<tr>
<td>城市:</td>
<td><select name="city"></select></td>
</tr>
</table>
</form>
<hr>
<button id="ctrlBtn">双击提交 · 右键重置</button>
<script>
var form = document.getElementById('contactForm');
var ctrlBtn = document.getElementById('ctrlBtn');
var citySelect = form.elements.city;
console.log('控件数量:', form.length);
console.log('控件集合:', form.elements);
['杭州', '成都', '深圳', '武汉'].forEach(function (name, i) {
citySelect.add(new Option(name, String(i)));
});
ctrlBtn.addEventListener('dblclick', function () {
form.submit();
});
ctrlBtn.addEventListener('contextmenu', function (e) {
e.preventDefault();
form.reset();
});
setTimeout(function () {
form.elements[0].focus();
setTimeout(function () { form.elements[0].blur(); }, 3000);
}, 5000);
form.elements[1].addEventListener('dblclick', function () {
this.select();
});
citySelect.addEventListener('change', function () {
if (this.length > 1) this.remove(this.selectedIndex);
});
</script>
</body>
</html>
代码注释:
form.elements可通过name或索引访问控件,适合统一校验与序列化。form.length、form.elements为只读;elements是 Live 集合,增删控件会实时反映。new Option(文本, value)等价于创建<option>,比createElement('option')更简洁。submit()/reset()可程序化提交、重置,常用于自定义按钮替代原生 submit。focus()/blur()/select()分别用于聚焦、失焦、选中输入框内文字。select.add()/remove(index)动态维护下拉选项;change时演示删除当前选中项。
经典场景 :登录页自动聚焦账号框、注册页「全选协议」、级联地址三级联动、自定义「清空表单」按钮、购物车结算前 form.submit()。
理论:表单提交的两种方式
| 方式 | 行为 |
|---|---|
用户点击 type="submit" |
触发表单 submit 事件 → 默认跳转 action |
JS 调用 form.submit() |
不触发 submit 事件,直接提交(常用于绕过校验时需自行处理) |
监听 submit 并 preventDefault() 是 Ajax 表单(如 Element Plus、Ant Design Form)的底层思路之一。
4.2 表格相关 API
名词:HTMLTableElement / HTMLTableRowElement / HTMLTableCellElement
| 对象 | 常用 API |
|---|---|
table |
rows、insertRow(index)、deleteRow(index) |
tr |
rowIndex、cells、insertCell(index)、deleteCell(index) |
td/th |
cellIndex(同行内索引) |
tr table 脚本 用户点击添加 tr table 脚本 用户点击添加 click insertRow() 新行引用 insertCell() × N 填充 innerHTML
完整示例:动态增删表格行
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>HTMLTableElement 动态表格</title>
<style>
table {
width: 800px;
/* table-layout: fixed --- 列宽由首行决定,长文本 ellipsis 时常用 */
table-layout: fixed;
/* border-collapse --- 合并边框,后台管理系统表格标配 */
border-collapse: collapse;
}
th, td {
padding: 10px;
border: 1px solid #ccc;
}
</style>
</head>
<body>
<input id="name" placeholder="姓名"><br>
<input id="addr" placeholder="地址"><br>
<input id="phone" placeholder="电话"><br>
<button id="addBtn">添加</button>
<hr>
<table id="dataTable">
<thead>
<tr><th>序号</th><th>姓名</th><th>地址</th><th>电话</th><th>操作</th></tr>
</thead>
<tbody>
<tr>
<td>1</td><td>小明</td><td>杭州</td><td>13800001111</td>
<td><button class="del">删除</button></td>
</tr>
</tbody>
</table>
<script>
var table = document.getElementById('dataTable');
var addBtn = document.getElementById('addBtn');
function bindDelete(btn) {
btn.onclick = function () {
var tr = btn.closest('tr');
table.deleteRow(tr.rowIndex);
};
}
document.querySelectorAll('.del').forEach(bindDelete);
addBtn.onclick = function () {
var tr = table.insertRow();
tr.insertCell(0).textContent = tr.rowIndex;
tr.insertCell(1).textContent = document.getElementById('name').value;
tr.insertCell(2).textContent = document.getElementById('addr').value;
tr.insertCell(3).textContent = document.getElementById('phone').value;
var opCell = tr.insertCell(4);
opCell.innerHTML = '<button class="del">删除</button>';
bindDelete(opCell.querySelector('.del'));
};
</script>
</body>
</html>
代码注释:
insertRow()无参时在表尾追加一行,返回新tr引用。insertCell(index)在指定位置插入单元格;tr.rowIndex为行索引(含thead时要注意偏移)。deleteRow(rowIndex)按索引删除整行;closest('tr')从按钮向上找到所在行。- 动态新增的行必须重新绑定删除事件;生产环境可用事件委托(见 9.4 节)优化。
经典场景:后台 CRUD 列表、购物车商品行、可编辑配置表、Excel 式在线表格的早期实现。
4.3 new Image() 快速创建图片
Image 构造函数是 HTMLImageElement 的工厂,等价于 document.createElement('img'),且可预加载:
javascript
var img = new Image(200, 100);
img.src = 'images/db01.jpg';
img.onload = function () {
document.body.appendChild(img);
};
代码注释 :new Image(w, h) 可指定初始尺寸;赋值 src 后触发加载,onload 回调中再插入页面。
完整示例
html
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>Image 构造函数</title></head>
<body>
<script>
var img = new Image(200, 100);
img.alt = '示例图';
img.src = 'images/db01.jpg';
img.onload = function () {
document.body.appendChild(img);
};
img.onerror = function () {
document.body.textContent = '图片加载失败,请确认 images 目录存在';
};
</script>
</body>
</html>
代码注释:
new Image(w, h)等价于document.createElement('img'),是HTMLImageElement的工厂方法。- 先设置
src触发请求,onload成功后再插入 DOM,避免空白闪烁。 onerror可在路径错误时显示占位或提示;常用于轮播预加载、Canvas 素材加载。
经典场景:轮播图预加载下一张、Canvas 绘制前加载素材、检测图片能否访问(onerror 显示占位图)。
五、事件机制深度解析
5.1 三种事件监听方式对比
| 方式 | 写法 | 同名事件多次绑定 | 解除方式 | 推荐度 |
|---|---|---|---|---|
| HTML 内联 | <button onclick="..."> |
仅第一个生效 | elem.onclick = null |
不推荐(混排结构与行为) |
| DOM0 | elem.onclick = fn |
后者覆盖前者 | elem.onclick = null |
简单场景可用 |
| DOM2 | addEventListener(type, fn) |
全部生效 | removeEventListener(type, fn) |
生产首选 |
DOM2
addEventListener
可多个共存
DOM0
elem.onclick = fn
后者覆盖
内联
onclick 属性
仅首个有效
完整示例:三种方式差异
html
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>三种事件监听</title></head>
<body>
<button id="btn1" onclick="console.log('第一次');" onclick="console.log('第二次');">
内联按钮(只会执行第一次)
</button>
<button id="btn2">DOM0 按钮</button>
<button id="btn3">DOM2 按钮</button>
<script>
var btn2 = document.getElementById('btn2');
btn2.onclick = function () { console.log('DOM0 第一次'); };
btn2.onclick = function () { console.log('DOM0 第二次(覆盖)'); };
var btn3 = document.getElementById('btn3');
btn3.addEventListener('click', function () { console.log('DOM2 第一次'); });
btn3.addEventListener('click', function () { console.log('DOM2 第二次'); });
</script>
</body>
</html>
代码注释:
- 内联
onclick:同一标签写多个同名属性,浏览器只认第一个(见btn1)。 - DOM0
elem.onclick = fn:后赋值覆盖前者,同名事件只保留最后一个。 - DOM2
addEventListener:可绑定多个同名监听器,触发时按注册顺序依次执行。
注:HTML 中写两个
onclick属性,浏览器只认第一个------这体现了内联方式的局限性。
业界实践 :React/Vue 等框架底层统一走 addEventListener;内联仅见于遗留项目或邮件模板。
5.2 解除事件监听
| 绑定方式 | 解除写法 | 注意 |
|---|---|---|
| 内联 / DOM0 | elem.onclick = null |
赋 null 即可 |
| DOM2 | removeEventListener('click', fn) |
必须传入同一函数引用 |
html
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>解除事件监听</title></head>
<body>
<button id="btn1" onclick="console.log('btn1 click')">内联</button>
<button id="btn2">DOM0</button>
<button id="btn3">DOM2</button>
<button id="unbindAll">一键解除上面三个</button>
<script>
var btn1 = document.getElementById('btn1');
var btn2 = document.getElementById('btn2');
var btn3 = document.getElementById('btn3');
btn2.onclick = function () { console.log('btn2'); };
function onBtn3Click() { console.log('btn3'); }
btn3.addEventListener('click', onBtn3Click);
document.getElementById('unbindAll').onclick = function () {
btn1.onclick = null;
btn2.onclick = null;
btn3.removeEventListener('click', onBtn3Click);
};
</script>
</body>
</html>
代码注释:
- 内联 / DOM0:赋
elem.onclick = null即可解除监听。 - DOM2:
removeEventListener(type, fn)必须传入与addEventListener时相同的函数引用。 - 匿名函数无法被移除,故
btn3使用具名函数onBtn3Click。
经典场景 :「只触发一次」的引导蒙层、{ once: true } 选项(现代写法)、组件销毁时防止内存泄漏。
5.3 事件流:捕获、目标、冒泡
根据 DOM 事件模型,一次点击会经历:
- 捕获阶段 :
window→document→ ... → 父元素 → 目标元素 - 目标阶段:到达目标
- 冒泡阶段 :目标 → 父元素 → ... →
window
addEventListener(type, listener, useCapture) 第三个参数:
false(默认):冒泡阶段执行true:捕获阶段执行
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>事件流演示</title>
<style>
div { padding: 60px; }
#box1 { width: 500px; background: #e74c3c; }
#box2 { background: #e67e22; }
#box3 { background: #f1c40f; }
#box4 { height: 80px; background: #2ecc71; }
</style>
</head>
<body>
<div id="box1">
<div id="box2">
<div id="box3">
<div id="box4"></div>
</div>
</div>
</div>
<script>
var box2 = document.getElementById('box2');
var box3 = document.getElementById('box3');
box2.addEventListener('click', function () {
console.log('box2 捕获阶段');
}, true);
box3.addEventListener('click', function () {
console.log('box3 冒泡阶段');
});
</script>
</body>
</html>
代码注释:
- 事件流:捕获(window → 目标)→ 目标阶段 → 冒泡(目标 → window)。
addEventListener第三参true表示捕获阶段执行,false或省略为冒泡(默认)。- 点击最内层
box4:先输出box2 捕获阶段,再输出box3 冒泡阶段。
渲染错误: Mermaid 渲染失败: Parse error on line 7: ...,B4: 捕获 ↓ W->>B1->>B2->>B4: 捕获传递 ----------------------^ Expecting 'TXT', got 'SOLID_ARROW'
事件流三阶段可视化
点击最内层元素时,完整传播路径如下:
捕获阶段 ↓ 冒泡阶段 ↑
window ──────────────────────────── window
│ ▲
▼ │
document ────────────────────────── document
│ ▲
▼ │
<html> ────────────────────────────<html>
│ ▲
▼ │
<body> ────────────────────────────<body>
│ ▲
▼ │
<div#box1> ──────────────────── <div#box1>
│ ▲
▼ │
<div#box2> ──────────────────── <div#box2>
│ ▲
└──────► 【目标元素 #box4】 ──────────┘
目标阶段
经典场景:
- 事件委托:在父元素冒泡阶段统一处理子元素点击(列表、表格动态行)。
- 拦截早于子元素:捕获阶段在父级做权限校验(较少用)。
stopPropagation():弹窗内点击不关闭外层遮罩。
5.4 事件回调中的 this
在 DOM0 与 DOM2 的普通监听函数中(非箭头函数),this 指向绑定事件的元素 (即 event.currentTarget)。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>事件中的 this</title></head>
<body>
<button id="btn" onclick="console.log('内联 this:', this)">内联</button>
<button id="btn2">DOM0</button>
<button id="btn3">DOM2</button>
<script>
document.getElementById('btn2').onclick = function () {
console.log('DOM0 this:', this);
};
document.getElementById('btn3').addEventListener('click', function () {
console.log('DOM2 this:', this);
});
</script>
</body>
</html>
代码注释:
- 普通
function作为监听器时,this指向绑定事件的元素(同event.currentTarget)。 - 内联
onclick中的this同样指向当前元素。 - 箭头函数不绑定自己的
this,会继承外层词法环境;bind可强行固定this。
对比:
| 写法 | this 指向 |
|---|---|
普通 function () {} |
绑定事件的元素 |
箭头函数 () => {} |
继承外层词法 this,不是元素 |
addEventListener(fn.bind(el)) |
被 bind 固定的对象 |
列表高亮案例见 [7.5 节](#7.5 节)。
5.5 事件对象:target、preventDefault、stopPropagation
监听器会收到事件对象 作为第一个参数(通常命名为 event 或 e),是处理交互细节的核心。
5.5.1 理论:三个高频属性/方法
| 成员 | 作用 | 典型场景 |
|---|---|---|
event.target |
用户实际交互的元素 | 事件委托:判断点的是按钮还是图标 |
event.currentTarget |
当前执行监听器的元素 | 与 this(普通函数)一致 |
event.preventDefault() |
阻止浏览器默认行为 | 阻止表单提交跳转、阻止右键菜单 |
event.stopPropagation() |
阻止事件继续传播 | 弹窗内点击不触发外层关闭 |
event.stopImmediatePropagation() |
阻止同级其他监听器 | 同一元素绑了多个监听器时 |
阻止
是
否
子元素 click
stopPropagation?
父元素收不到
冒泡到父元素
委托
匹配 .btn
不匹配
父元素 listener
event.target 是谁?
执行业务逻辑
忽略
5.5.2 完整示例:右键菜单与阻止冒泡
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>事件对象演示</title>
<style>
#outer { padding: 40px; background: #ecf0f1; }
#inner { padding: 40px; background: #3498db; color: #fff; }
#menu { display: none; position: fixed; background: #fff; border: 1px solid #ccc; padding: 8px 0; }
#menu li { padding: 8px 20px; list-style: none; cursor: pointer; }
</style>
</head>
<body>
<div id="outer">外层
<div id="inner">内层(右键显示菜单,点击不冒泡到外层)</div>
</div>
<ul id="menu">
<li>复制</li>
<li>粘贴</li>
</ul>
<script>
var outer = document.getElementById('outer');
var inner = document.getElementById('inner');
var menu = document.getElementById('menu');
inner.addEventListener('contextmenu', function (e) {
e.preventDefault();
menu.style.display = 'block';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
});
inner.addEventListener('click', function (e) {
e.stopPropagation();
menu.style.display = 'none';
});
outer.addEventListener('click', function () {
console.log('外层被点击------若内层未 stopPropagation 则会执行');
});
document.addEventListener('click', function () {
menu.style.display = 'none';
});
</script>
</body>
</html>
代码注释:
contextmenu+preventDefault()阻止系统右键菜单,改用手写#menu定位显示。clientX/clientY为鼠标在视口中的坐标,用于固定定位菜单。- 内层
click使用stopPropagation(),避免触发外层的click监听。 - 文档级
click用于点击空白处关闭菜单;实际项目可用「遮罩层」优化体验。
经典场景 :自定义右键菜单、拖拽时禁止选中文字、表单 submit 前 preventDefault 做 Ajax 提交、链接 click 拦截改 SPA 路由。
5.5.3 现代 API:addEventListener 选项对象
除第三个布尔参数外,还可传入配置对象(MDN 选项说明):
javascript
element.addEventListener('click', handler, {
capture: false, // 是否捕获阶段
once: true, // 触发一次后自动移除
passive: true // 不调用 preventDefault(滚动优化)
});
代码注释 :once: true 适合新手引导、一次性确认;passive: true 常用于 touchstart / wheel,告诉浏览器不会阻止默认滚动,提升流畅度。
六、本课涉及的 CSS 与业界应用
下面按样式属性逐一说明,并对应本课案例中的用法与真实产品场景。
6.1 box-sizing: border-box
css
input, select, textarea {
box-sizing: border-box;
width: 300px;
padding: 10px;
}
代码注释 :声明 width: 300px 时,padding 与 border 计入宽度,元素总宽仍为 300px,不会「撑破」布局。
| 项目 | 说明 |
|---|---|
| 含义 | width 包含 padding 和 border,不再向外膨胀 |
| 默认 | content-box(padding/border 会撑大盒子) |
| 业界 | Bootstrap、Ant Design、Element Plus 全局 *, *::before, *::after { box-sizing: border-box } |
| 场景 | 固定宽度表单、栅格布局中列宽精确计算 |
6.2 vertical-align
css
.label-top { vertical-align: top; }
用于表格布局中,让左侧文字标签与右侧多行 textarea 顶对齐。
业界 :传统 table 表单布局的邮箱/备注行;现多采用 Flex/Grid 替代,但理解 vertical-align 仍有助于处理 inline-block 间隙问题。
6.3 table-layout: fixed + border-collapse: collapse
css
table {
table-layout: fixed;
width: 800px;
border-collapse: collapse;
}
| 属性 | 作用 | 业界场景 |
|---|---|---|
table-layout: fixed |
列宽由第一行决定,内容过长不撑开表格 | 后台数据表、固定列宽报表 |
border-collapse: collapse |
相邻单元格边框合并 | 几乎所有管理后台表格 |
6.4 Flex 布局
css
.wrapper {
display: flex;
flex-wrap: wrap;
width: 1100px;
}
.wrapper img {
margin: 10px;
width: 480px;
height: 480px;
}
懒加载瀑布流/宫格 :flex-wrap 让图片自动换行; Pinterest 早期、电商商品列表常用类似布局。
css
.tab-nav {
display: flex;
height: 40px;
line-height: 40px;
}
选项卡导航:淘宝「我的订单」、京东订单中心顶部 Tab 均为横向 flex + 底部边框高亮。
css
.scroll-box {
display: flex;
overflow: hidden;
width: 800px;
}
.scroll-box img {
flex: 0 0 auto;
width: 200px;
}
无缝滚动 :flex 横向排列 + overflow: hidden 裁切;天猫首页品牌墙、LOGO 走马灯同类原理。
6.5 overflow: hidden
裁切超出容器的内容,是无缝滚动的视觉基础。配合 scrollLeft 移动内部内容。
6.6 状态类 .active
css
.tab-nav li.active {
background: #fff;
height: 41px; /* 盖住底边框,形成「嵌入」效果 */
}
.tab-content li.active {
display: block;
}
排他切换 :导航与内容区同步添加/移除 active。GitHub 仓库 Settings 子导航、云控制台左侧菜单均为此模式。
css
#list li.active {
color: #fff;
background: #c0392b;
}
列表选中高亮:收件箱邮件列表、播放列表当前曲目。
6.7 list-style: none
去除 <ul> 默认圆点,导航与 Tab 必备。结合 padding: 0; margin: 0 重置浏览器默认样式。
七、经典综合案例(完整可运行)
以下示例图片路径均为
images/db01.jpg等形式,请将 HTML 放在与images文件夹同级目录下运行。
7.1 图片延迟加载(Lazy Load)
业界 :知乎想法流、微博图片流、Medium 文章配图;现代可用 loading="lazy" 属性,但理解手动实现有助于掌握性能优化。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>图片延迟加载</title>
<style>
.wrapper {
display: flex;
flex-wrap: wrap;
margin: 20px auto;
width: 1100px;
}
.wrapper img {
margin: 10px;
width: 480px;
height: 480px;
}
</style>
</head>
<body>
<div class="wrapper">
<img data-src="./images/db01.jpg">
<img data-src="./images/db02.jpg">
<img data-src="./images/db03.jpg">
<img data-src="./images/db04.jpg">
<img data-src="./images/db05.jpg">
<img data-src="./images/db06.jpg">
<img data-src="./images/db07.jpg">
<img data-src="./images/db08.jpg">
<img data-src="./images/db09.jpg">
<img data-src="./images/db10.jpg">
<img data-src="./images/db01.jpg">
<img data-src="./images/db02.jpg">
<img data-src="./images/db03.jpg">
<img data-src="./images/db04.jpg">
<img data-src="./images/db05.jpg">
<img data-src="./images/db06.jpg">
<img data-src="./images/db07.jpg">
<img data-src="./images/db08.jpg">
<img data-src="./images/db09.jpg">
<img data-src="./images/db10.jpg">
<img data-src="./images/db01.jpg">
<img data-src="./images/db02.jpg">
<img data-src="./images/db03.jpg">
<img data-src="./images/db04.jpg">
<img data-src="./images/db05.jpg">
<img data-src="./images/db06.jpg">
<img data-src="./images/db07.jpg">
<img data-src="./images/db08.jpg">
<img data-src="./images/db09.jpg">
<img data-src="./images/db10.jpg">
<img data-src="./images/db01.jpg">
<img data-src="./images/db02.jpg">
<img data-src="./images/db03.jpg">
<img data-src="./images/db04.jpg">
<img data-src="./images/db05.jpg">
<img data-src="./images/db06.jpg">
<img data-src="./images/db07.jpg">
<img data-src="./images/db08.jpg">
<img data-src="./images/db09.jpg">
<img data-src="./images/db10.jpg">
<img data-src="./images/db01.jpg">
<img data-src="./images/db02.jpg">
<img data-src="./images/db03.jpg">
<img data-src="./images/db04.jpg">
<img data-src="./images/db05.jpg">
<img data-src="./images/db06.jpg">
<img data-src="./images/db07.jpg">
<img data-src="./images/db08.jpg">
<img data-src="./images/db09.jpg">
<img data-src="./images/db10.jpg">
<img data-src="./images/db01.jpg">
<img data-src="./images/db02.jpg">
<img data-src="./images/db03.jpg">
<img data-src="./images/db04.jpg">
<img data-src="./images/db05.jpg">
<img data-src="./images/db06.jpg">
<img data-src="./images/db07.jpg">
<img data-src="./images/db08.jpg">
<img data-src="./images/db09.jpg">
<img data-src="./images/db10.jpg">
<img data-src="./images/db01.jpg">
<img data-src="./images/db02.jpg">
<img data-src="./images/db03.jpg">
<img data-src="./images/db04.jpg">
<img data-src="./images/db05.jpg">
<img data-src="./images/db06.jpg">
<img data-src="./images/db07.jpg">
<img data-src="./images/db08.jpg">
<img data-src="./images/db09.jpg">
<img data-src="./images/db10.jpg">
</div>
<script>
(function() {
// 获取所有的图片
var imgItems = document.querySelectorAll('.wrapper img');
// 根据 NodeList 对象创建了新的纯数组
var imgArr = [].slice.call(imgItems, 0);
// 加载首屏图片
loadImage();
// 监听页面发生滚动
window.onscroll = loadImage;
// 加载图片的函数
function loadImage() {
// 遍历所有的图片
imgArr.forEach(function(imgItem, index) {
// 判断本图片与视口顶部的距离 是否小于 视口高度
if (imgItem.getBoundingClientRect().top < document.documentElement.clientHeight) {
// 给图片设置 src 属性
imgItem.src = imgItem.dataset.src;
// 将该元素从 imgArr 中删除
delete imgArr[index];
}
});
}
})();
</script>
</body>
</html>
代码注释:
- 原理 :首屏不写
src,用data-src存真实地址,进入视口后再赋值,减少首屏请求。 getBoundingClientRect().top < clientHeight判断图片是否进入视口。img.src = img.dataset.src触发加载;delete imgArr[index]从数组中删除已加载的图片。loadImage()在页面加载时执行一次,然后监听scroll事件。[].slice.call(imgItems, 0)将 NodeList 转换为真正的数组。
进阶:Intersection Observer API、占位图、渐进式 JPEG、骨架屏。
7.2 无缝横向滚动
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>无缝滚动</title>
<style>
.wrapper {
margin: 100px auto;
width: 800px;
height: 200px;
display: flex;
overflow: hidden;
}
.wrapper img {
flex: 0 0 auto;
width: 200px;
height: 200px;
}
</style>
</head>
<body>
<div id="box" class="wrapper">
<img src="./images/db01.jpg" alt="">
<img src="./images/db02.jpg" alt="">
<img src="./images/db03.jpg" alt="">
<img src="./images/db04.jpg" alt="">
<img src="./images/db05.jpg" alt="">
<img src="./images/db06.jpg" alt="">
<img src="./images/db07.jpg" alt="">
<img src="./images/db08.jpg" alt="">
<img src="./images/db09.jpg" alt="">
<img src="./images/db10.jpg" alt="">
</div>
<script>
(function() {
// 获取包裹元素
var box = document.querySelector('#box');
// 获取一张图片的宽度
var imgWidth = box.firstElementChild.offsetWidth;
// 获取图片的数量
var imgLength = box.children.length;
// 将所有的图片复制一份
box.innerHTML += box.innerHTML;
// 开启定时器
setInterval(function() {
box.scrollLeft += 1;
// 判断临界值
if (box.scrollLeft >= imgWidth * imgLength) {
box.scrollLeft = 0;
}
}, 10);
})();
</script>
</body>
</html>
代码注释:
- 原理 :复制一份 DOM(
innerHTML += innerHTML)实现双倍内容,滚动到原宽度时归零,视觉无缝。 imgWidth为单张图片宽度;imgLength为原始图片数量(复制前)。scrollLeft += 1每 10ms 右移 1px;>= imgWidth * imgLength时重置为 0。scrollLeft可读写,是横向滚动的核心属性。
业界 :电商品牌墙、新闻标题跑马灯;CSS animation + transform 是更流畅的现代替代。
7.3 随机点名器
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>随机点名器</title>
<style>
.wrapper {
margin: 100px auto;
width: 400px;
text-align: center;
}
#box {
line-height: 150px;
font-size: 80px;
}
#btn {
width: 200px;
height: 80px;
font-size: 40px;
border: none;
}
</style>
</head>
<body>
<div class="wrapper">
<div id="box">随机点名器</div>
<button id="btn">开始</button>
</div>
<script>
(function() {
// 定义数组 包含姓名列表
var nameList = ['曹操', '刘备', '孙权', '诸葛亮', '吕布', '关羽', '张飞', '孙尚香', '司马懿', '周瑜', '邢道荣', '夏侯惇', '公孙瓒', '孙悟空', '尉迟敬德', '秦琼'];
// 获取元素
var box = document.querySelector('#box');
var btn = document.querySelector('#btn');
var intervalId = null;
// 点击按钮的事件
btn.onclick = function() {
// 判断按钮上的文字
if (btn.innerHTML === '停止') {
// 停止定时器
clearInterval(intervalId);
// 修改按钮上的文字
btn.innerHTML = '继续';
} else {
// 开启定时器
intervalId = setInterval(function() {
// 随机从数组取出一个元素 作为box的内容
box.innerHTML = nameList[Math.floor(Math.random() * nameList.length)];
}, 100);
// 修改按钮上的文字
btn.innerHTML = '停止';
}
}
})();
</script>
</body>
</html>
代码注释:
- 原理 :
setInterval每 100ms 随机取姓名写入box;按钮在「开始/停止/继续」间切换。 intervalId保存定时器 ID;clearInterval(intervalId)停止滚动。Math.floor(Math.random() * nameList.length)生成随机索引。btn.innerHTML用于判断和修改按钮文字。
经典场景:课堂抽签、年会抽奖、直播间随机禁言检测(逻辑类似)。
7.4 选项卡(Tab)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>选项卡</title>
<style>
ul {
margin: 0;
padding: 0;
list-style: none;
}
.tab {
margin: 100px auto;
width: 800px;
}
.tab-nav {
display: flex;
height: 40px;
line-height: 40px;
border-left: 1px solid #ccc;
border-bottom: 1px solid #ccc;
}
.tab-nav li {
padding: 0 20px;
background: #f5f5f5;
border-top: 1px solid #ccc;
border-right: 1px solid #ccc;
}
.tab-nav li.active {
height: 40px;
background: #fff;
}
.tab-content li {
display: none;
min-height: 200px;
padding: 20px;
border: 1px solid #ccc;
border-top: none;
}
.tab-content li.active {
display: block;
}
</style>
</head>
<body>
<div class="tab">
<ul class="tab-nav">
<li class="active">未付款订单</li>
<li>未发货订单</li>
<li>未收货订单</li>
<li>未评价订单</li>
<li>已完成订单</li>
</ul>
<ul class="tab-content">
<li class="active">这是未付款订单</li>
<li>这是未发货订单</li>
<li>这是未收货订单</li>
<li>这是未评价订单</li>
<li>这是已完成订单</li>
</ul>
</div>
<script>
(function() {
// 获取元素
var tabNavItems = document.querySelectorAll('.tab-nav li');
var tabContentItems = document.querySelectorAll('.tab-content li');
// 遍历选项卡导航 每一个添加单击事件
tabNavItems.forEach(function(tabNavItem, index) {
tabNavItem.onclick = function() {
// 排他
// 把所有的tabNav都取消选中 把所有的tabContent隐藏
for (var i = 0; i < tabNavItems.length; i ++) {
tabNavItems[i].classList.remove('active');
tabContentItems[i].classList.remove('active');
}
// 当前点击添加 active 类名 表示当前选中
tabNavItem.classList.add('active');
// 与当前tabNav对应的tabContent要显示出来
tabContentItems[index].classList.add('active');
}
});
})()
</script>
</body>
</html>
代码注释:
- 排他思想 :点击 Tab 时,先用
for循环移除所有导航与面板的active,再给当前项加上。 tabNavItems与tabContentItems通过forEach的index一一对应。.tab-content li默认display: none,.active时display: block。- 使用
onclick而非addEventListener,与课堂案例保持一致。
优化方向 :事件委托绑定在 .tab-nav 上;ARIA role="tablist" 提升无障碍;Vue v-for + 响应式 index 是同一思想的组件化。
7.5 列表高亮切换(this 实战)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>列表高亮 this</title>
<style>
#menu { width: 600px; list-style: none; padding: 0; }
#menu li { padding: 12px; border-bottom: 1px dashed #bbb; cursor: pointer; }
#menu li.active { color: #fff; background: #2980b9; }
</style>
</head>
<body>
<ul id="menu">
<li>首页推荐</li>
<li>前端工程化</li>
<li>Node.js 入门</li>
<li>TypeScript 实践</li>
<li>性能优化专题</li>
</ul>
<script>
var items = document.querySelectorAll('#menu li');
for (var i = 0; i < items.length; i++) {
items[i].onclick = function () {
this.classList.toggle('active');
};
}
</script>
</body>
</html>
代码注释:
- 坑 :
for循环里若写items[i].classList.toggle('active'),回调执行时i已是length,会失效。 - 正确 :回调内用
this,this指向被点击的<li>元素。 classList.toggle('active')切换高亮类名;也可用forEach+addEventListener达到同样效果。
对比闭包写法:
javascript
items.forEach(function (li) {
li.addEventListener('click', function () {
this.classList.toggle('active');
});
});
代码注释 :forEach 每次迭代有独立作用域,回调里的 this 仍指向被点击的 li,可替代 for + var i 的写法。
7.6 手风琴菜单(排他进阶)
手风琴是「排他思想」的典型进阶场景:一次只展开一项,其余项收起。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>手风琴菜单</title>
<style>
.accordion { width: 400px; margin: 60px auto; border: 1px solid #ddd; border-radius: 4px; overflow: hidden; }
.item-header {
padding: 14px 18px;
background: #f5f5f5;
cursor: pointer;
font-weight: bold;
display: flex;
justify-content: space-between;
border-bottom: 1px solid #ddd;
user-select: none;
}
.item-header:hover { background: #e8e8e8; }
.item-header.active { background: #3498db; color: #fff; }
.item-body {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
padding: 0 18px;
background: #fff;
}
.item-body.active {
max-height: 200px;
padding: 14px 18px;
}
.arrow { transition: transform 0.3s; }
.item-header.active .arrow { transform: rotate(180deg); }
</style>
</head>
<body>
<div class="accordion" id="accordion">
<div class="accordion-item">
<div class="item-header">HTML 基础 <span class="arrow">▼</span></div>
<div class="item-body">标签、属性、语义化、表单、表格......</div>
</div>
<div class="accordion-item">
<div class="item-header">CSS 进阶 <span class="arrow">▼</span></div>
<div class="item-body">Flex、Grid、动画、响应式布局......</div>
</div>
<div class="accordion-item">
<div class="item-header">JavaScript DOM <span class="arrow">▼</span></div>
<div class="item-body">事件、DOM 操作、BOM、异步......</div>
</div>
<div class="accordion-item">
<div class="item-header">前端工程化 <span class="arrow">▼</span></div>
<div class="item-body">Webpack、Vite、NPM、模块化......</div>
</div>
</div>
<script>
var accordion = document.getElementById('accordion');
var headers = accordion.querySelectorAll('.item-header');
var bodies = accordion.querySelectorAll('.item-body');
headers.forEach(function (header, index) {
header.addEventListener('click', function () {
var isActive = header.classList.contains('active');
// 排他:先关闭所有
headers.forEach(function (h) { h.classList.remove('active'); });
bodies.forEach(function (b) { b.classList.remove('active'); });
// 若原来未展开,则展开当前
if (!isActive) {
header.classList.add('active');
bodies[index].classList.add('active');
}
});
});
</script>
</body>
</html>
代码注释:
- 排他核心 :先全部关闭(移除
active),再根据原状态决定是否打开当前项。 isActive记录点击前的状态------若原来已展开则点击后关闭(切换效果)。max-height+transition实现动画展开;overflow: hidden配合max-height: 0隐藏内容。- 箭头用 CSS
transform: rotate(180deg)翻转,无需额外 JS。
经典场景:FAQ 问答列表、侧边导航分组、移动端导航折叠、商品筛选条件展开。
7.7 弹窗 Modal(点遮罩关闭)
Modal 弹窗是 stopPropagation 与 event.target 判断的经典实战。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>弹窗 Modal</title>
<style>
.overlay {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,.5);
justify-content: center;
align-items: center;
}
.overlay.show { display: flex; }
.modal {
background: #fff;
border-radius: 8px;
padding: 30px 36px;
min-width: 320px;
position: relative;
}
.modal h3 { margin: 0 0 12px; }
.close-btn {
position: absolute; top: 10px; right: 14px;
background: none; border: none;
font-size: 20px; cursor: pointer; color: #999;
}
.close-btn:hover { color: #333; }
#openBtn { padding: 10px 24px; font-size: 15px; cursor: pointer; }
</style>
</head>
<body>
<button id="openBtn">打开弹窗</button>
<div class="overlay" id="overlay">
<div class="modal" id="modal">
<button class="close-btn" id="closeBtn">✕</button>
<h3>提示</h3>
<p>点击遮罩或右上角 ✕ 可关闭弹窗。<br>点击弹窗内部不会关闭。</p>
</div>
</div>
<script>
var overlay = document.getElementById('overlay');
var modal = document.getElementById('modal');
var openBtn = document.getElementById('openBtn');
var closeBtn = document.getElementById('closeBtn');
openBtn.addEventListener('click', function () {
overlay.classList.add('show');
});
closeBtn.addEventListener('click', function () {
overlay.classList.remove('show');
});
// 点击遮罩关闭:判断点的是遮罩本身,而非内部弹窗
overlay.addEventListener('click', function (e) {
if (e.target === overlay) {
overlay.classList.remove('show');
}
});
</script>
</body>
</html>
代码注释:
- 遮罩
overlay使用position: fixed; inset: 0铺满全屏;Flex 居中弹窗内容。 - 关闭遮罩的关键:
e.target === overlay------仅当用户点的是遮罩层本身时才关闭,点弹窗内部(e.target为 modal 子元素)则不关闭。 - 这是利用冒泡 :点 modal 内部 → 事件冒泡到 overlay → 但
e.target不是 overlay → 不关闭。 - 另一种写法是在 modal 内部加
e.stopPropagation(),效果相同但耦合度更高。
经典场景:确认删除弹窗、登录框、图片预览、Cookie 同意提示、活动弹窗。
7.8 实时字数统计
input 事件的典型应用------每次输入立即反馈。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>实时字数统计</title>
<style>
.editor-wrap { width: 500px; margin: 60px auto; }
textarea {
width: 100%; height: 140px;
box-sizing: border-box;
padding: 10px; font-size: 14px;
border: 1px solid #ccc; border-radius: 4px;
resize: vertical;
}
.counter {
text-align: right; font-size: 13px;
color: #999; margin-top: 4px;
}
.counter.warn { color: #e74c3c; }
</style>
</head>
<body>
<div class="editor-wrap">
<textarea id="content" maxlength="200" placeholder="最多 200 字......"></textarea>
<p class="counter" id="counter">0 / 200</p>
</div>
<script>
var content = document.getElementById('content');
var counter = document.getElementById('counter');
var MAX = 200;
content.addEventListener('input', function () {
var len = this.value.length;
counter.textContent = len + ' / ' + MAX;
// 超过 80% 时变红提醒
counter.classList.toggle('warn', len >= MAX * 0.8);
});
</script>
</body>
</html>
代码注释:
input事件在每次输入、删除、粘贴 时实时触发,比change(失焦才触发)更及时。this.value.length获取当前字符数;counter.textContent同步更新显示。classList.toggle(className, condition)第二个参数为布尔值,true添加类、false移除类,简化了 if/else。maxlength="200"由 HTML 控制最大输入,JS 只负责展示计数。
经典场景:微博发帖字数限制、评论框、简历填写、搜索关键词实时提示、密码强度检测。
7.9 事件委托------动态任务列表(完整版)
事件委托让动态新增的元素无需单独绑定事件,是前端开发的重要范式。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>任务列表(事件委托)</title>
<style>
.todo-wrap { width: 400px; margin: 60px auto; }
.add-row { display: flex; gap: 8px; margin-bottom: 12px; }
.add-row input { flex: 1; padding: 8px 10px; border: 1px solid #ccc; border-radius: 4px; }
.add-row button { padding: 8px 16px; background: #3498db; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
#list { list-style: none; padding: 0; margin: 0; }
#list li {
display: flex; align-items: center; gap: 8px;
padding: 10px 12px; border-bottom: 1px solid #eee;
}
#list li.done span { text-decoration: line-through; color: #bbb; }
#list li input[type="checkbox"] { cursor: pointer; }
#list li .del {
margin-left: auto; background: none; border: none;
color: #e74c3c; cursor: pointer; font-size: 16px;
}
.stats { font-size: 13px; color: #999; margin-top: 10px; }
</style>
</head>
<body>
<div class="todo-wrap">
<div class="add-row">
<input id="taskInput" placeholder="输入任务,按 Enter 添加">
<button id="addBtn">添加</button>
</div>
<ul id="list">
<li><input type="checkbox"><span>学习 DOM 事件</span><button class="del">✕</button></li>
<li><input type="checkbox"><span>完成课堂练习</span><button class="del">✕</button></li>
</ul>
<p class="stats" id="stats"></p>
</div>
<script>
var list = document.getElementById('list');
var taskInput = document.getElementById('taskInput');
var addBtn = document.getElementById('addBtn');
var stats = document.getElementById('stats');
// 更新底部统计
function updateStats() {
var total = list.children.length;
var done = list.querySelectorAll('li.done').length;
stats.textContent = '共 ' + total + ' 项,已完成 ' + done + ' 项';
}
// 添加任务
function addTask() {
var text = taskInput.value.trim();
if (!text) return;
var li = document.createElement('li');
li.innerHTML = '<input type="checkbox"><span>' + text + '</span><button class="del">✕</button>';
list.appendChild(li);
taskInput.value = '';
updateStats();
}
addBtn.addEventListener('click', addTask);
taskInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') addTask();
});
// 事件委托:在父 ul 上统一处理勾选和删除
list.addEventListener('click', function (e) {
var li = e.target.closest('li');
if (!li) return;
// 点击删除按钮
if (e.target.classList.contains('del')) {
li.remove();
updateStats();
return;
}
// 点击复选框
if (e.target.type === 'checkbox') {
li.classList.toggle('done', e.target.checked);
updateStats();
}
});
updateStats();
</script>
</body>
</html>
代码注释:
- 事件委托核心 :所有
click统一监听在#list(父元素),不给每个li单独绑定。 e.target.closest('li')从点击元素向上找到所在li,处理点到span文字时的情况。e.target.classList.contains('del')判断点的是删除按钮。e.target.type === 'checkbox'判断点的是复选框。li.classList.toggle('done', e.target.checked)根据勾选状态同步类名。- 动态新增的任务(
appendChild)无需再绑定事件,委托自动生效。
委托 vs 逐个绑定对比:
| 方式 | 代码量 | 动态新增支持 | 内存占用 |
|---|---|---|---|
| 逐个绑定 | 多 | ✗(需手动重绑) | 多(N 个监听器) |
| 事件委托 | 少 | ✓(自动生效) | 少(1 个监听器) |
经典场景:Todo 应用、购物车列表、评论区、消息通知列表、动态菜单项。
八、常用事件速查(进阶预习)
下表为下一阶段常用事件,便于与本课 API 衔接。
8.1 鼠标事件
| 事件 | 说明 |
|---|---|
click / dblclick |
单击 / 双击 |
contextmenu |
右键菜单 |
mousedown / mouseup / mousemove |
按下 / 抬起 / 移动 |
mouseenter / mouseleave |
进入 / 离开(不冒泡) |
mouseover / mouseout |
进入 / 离开(会冒泡) |
wheel / DOMMouseScroll |
滚轮(注意兼容) |
理论补充 :mouseenter 仅在指针进入当前元素 时触发一次;mouseover 进入子元素时也会再次触发(因冒泡)。下拉菜单悬停、Tooltip 多用 mouseenter / mouseleave。
mouseenter vs mouseover 完整示例
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>mouseenter vs mouseover</title>
<style>
.box { width: 200px; background: #3498db; color: #fff; padding: 20px; margin: 20px; }
.box span { display: inline-block; background: #e74c3c; padding: 5px 10px; }
#log { font-size: 13px; margin: 10px 20px; }
</style>
</head>
<body>
<div class="box" id="enter">
mouseenter 父元素
<span>子元素</span>
</div>
<div class="box" id="over">
mouseover 父元素
<span>子元素</span>
</div>
<p id="log">悬停上方查看差异</p>
<script>
var log = document.getElementById('log');
var enterCount = 0, overCount = 0;
document.getElementById('enter').addEventListener('mouseenter', function () {
log.textContent = 'mouseenter 触发次数:' + (++enterCount) + '(进入子元素不重复触发)';
});
document.getElementById('over').addEventListener('mouseover', function () {
log.textContent = 'mouseover 触发次数:' + (++overCount) + '(进入子元素会再次触发)';
});
</script>
</body>
</html>
代码注释:
- 鼠标从父元素移到子元素时,
mouseenter不会重复触发(无冒泡),计数保持不变。 - 同样操作,
mouseover会因子元素事件冒泡到父元素而再次触发,计数增加。 - 实际开发:下拉菜单、Tooltip 用
mouseenter/mouseleave;需要委托的悬停效果才用mouseover/mouseout。
滚轮兼容示例:
javascript
function onWheel(e) {
if (e.wheelDelta) {
console.log(e.wheelDelta < 0 ? '向下' : '向上');
} else if (e.detail) {
console.log(e.detail > 0 ? '向下' : '向上');
}
}
window.addEventListener('wheel', onWheel);
window.addEventListener('DOMMouseScroll', onWheel);
代码注释 :event.button 表示鼠标键(0 左、1 中、2 右);位置用 offsetX/Y(相对元素)、clientX/Y(视口)、pageX/Y(页面)。Chrome 用 wheel + wheelDelta,旧版 Firefox 用 DOMMouseScroll + detail。
8.2 键盘事件
| 事件 | 触发时机 | 说明 |
|---|---|---|
keydown |
键按下 | 可识别功能键(方向键、Ctrl、Esc) |
keypress |
键按下且产生字符 | 已不推荐,部分浏览器已废弃 |
keyup |
键抬起 | 常用于组合键释放检测 |
keydown 与 keypress 的区别:
keydown:任意键都会触发,包括 Shift、方向键、F1~F12。keypress:仅当按键能产生「字符」时触发(如字母、数字),对功能键无效。- 实际开发优先使用
keydown,用event.key或event.code判断按键(MDN KeyboardEvent)。
哪些元素可以监听键盘事件?
必须是可获得焦点 的元素:input、textarea、select、button、<a href>、以及设置了 tabindex="0" 的任意元素。div 默认不能打字,需 tabindex 或 focus() 后才能接收键盘事件。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>键盘事件</title></head>
<body>
<input id="search" placeholder="按 Enter 搜索">
<div id="box" tabindex="0" style="outline:1px dashed #999;padding:20px;margin-top:10px;">
点此聚焦后按方向键
</div>
<p id="log"></p>
<script>
var log = document.getElementById('log');
document.getElementById('search').addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
log.textContent = '搜索:' + this.value;
}
});
document.getElementById('box').addEventListener('keydown', function (e) {
log.textContent = '按键:' + e.key + '(code: ' + e.code + ')';
});
</script>
</body>
</html>
代码注释:
e.key为键名(如Enter、ArrowDown);e.code为物理键位(如KeyA),布局无关时更稳定。input上Enter常配合preventDefault阻止意外提交。- 非表单元素需
tabindex="0"才能被 Tab 聚焦并接收键盘事件。
经典场景:搜索框回车提交、快捷键(Ctrl+S)、游戏方向键、Esc 关闭弹窗、输入框限制只能输入数字。
8.3 文档事件
| 事件 | 说明 |
|---|---|
DOMContentLoaded |
HTML 解析完成,不等待图片、样式表、子框架 |
load |
页面及所有依赖资源(图、样式、iframe)加载完成 |
beforeunload |
即将离开页面,可弹确认框(需用户交互过) |
unload |
文档正在卸载(现代更推荐 pagehide) |
load 与 DOMContentLoaded 的区别:
javascript
document.addEventListener('DOMContentLoaded', function () {
console.log('DOM 就绪,可操作节点');
});
window.addEventListener('load', function () {
console.log('所有资源加载完,可安全读图片尺寸');
});
代码注释:
- 操作 DOM、绑定事件 → 用
DOMContentLoaded,不必等图片,首屏脚本更快执行。 - 依赖图片宽高、iframe 内容的逻辑 → 用
window.onload或图片自身的onload。 - SPA 时代路由切换不再整页
load,但理解二者差异有助于写初始化逻辑。
经典场景 :DOMContentLoaded 初始化 Tab/轮播;load 统计首屏完整加载时间;beforeunload 表单未保存提示。
8.4 表单与图片事件
| 类型 | 事件 | 触发时机 | 经典场景 |
|---|---|---|---|
| 表单 | input |
值变化时实时触发 | 搜索联想、字数统计 |
| 表单 | change |
失焦且值改变后 | 下拉切换、勾选协议 |
| 表单 | submit |
点击提交或 Enter | Ajax 登录、校验拦截 |
| 表单 | focus / blur |
获焦 / 失焦 | 浮动标签、错误提示 |
| 图片 | load |
图片加载成功 | 懒加载替换占位、计算宽高 |
| 图片 | error |
加载失败 | 显示默认头像、重试 |
表单
input
change
submit
文档
DOMContentLoaded
load
鼠标
click
dblclick
mousemove
九、知识点归纳与对比表
9.1 事件绑定一句话总结
内联覆盖首个,DOM0 覆盖最后一个,DOM2 全部保留;解除 DOM2 必须同一函数引用。
9.2 HTML DOM vs 通用 DOM
| 需求 | 推荐 API |
|---|---|
| 动态下拉选项 | new Option() + select.add() |
| 动态表格行 | table.insertRow() + tr.insertCell() |
| 预加载图片 | new Image() + onload |
| 普通 div | createElement('div') |
9.3 案例技术栈对照
| 案例 | 核心 API | 核心 CSS |
|---|---|---|
| 懒加载 | scroll、getBoundingClientRect、dataset |
flex-wrap |
| 无缝滚动 | scrollLeft、setInterval |
flex、overflow:hidden |
| 点名器 | setInterval / clearInterval |
字号行高 |
| 选项卡 | classList、排他 |
.active、flex |
| 列表高亮 | this、classList.toggle |
.active |
| 动态表格 | insertRow、deleteRow |
table-layout、border-collapse |
| 表单 | form.elements、Option |
box-sizing |
| 事件流 | addEventListener(..., true) |
嵌套 padding 可视化 |
| 手风琴 | classList 排他 + isActive |
max-height 动画 |
| Modal 弹窗 | e.target === overlay |
position: fixed; inset: 0 |
| 字数统计 | input 事件 + length |
classList.toggle(cls, bool) |
| 任务列表 | 事件委托 closest + matches |
text-decoration: line-through |
9.4 事件委托模板(扩展)
javascript
document.getElementById('list').addEventListener('click', function (e) {
if (e.target.matches('.del-btn')) {
e.target.closest('tr').remove();
}
});
代码注释:
- 在父元素上监听
click,利用冒泡统一处理子元素点击(事件委托)。 e.target.matches('.del-btn')判断实际点击的是否为删除按钮。closest('tr')找到所在行后删除;动态新增行无需再单独绑定事件。
动态表格添加行时,无需逐行绑定删除按钮------这是事件冒泡带来的工程价值。
十、学习路线与最佳实践
熟练通用 DOM
HTMLFormElement / HTMLTableElement
addEventListener + 事件流
this 与排他思想
综合案例实战
事件对象 + 委托 + 性能
最佳实践清单:
- 优先
addEventListener,避免内联onclick。 - 动态列表用事件委托,减少监听器数量。
- 需要解绑时保存具名函数引用,便于
removeEventListener。 - 图片场景掌握
data-src懒加载思路;新项目可结合loading="lazy"。 - 操作表格行注意
rowIndex与thead对索引的影响。 - 在循环中绑定事件,用
this或forEach闭包,避免var i陷阱。 - 组件卸载/路由离开时移除全局
window监听,防止内存泄漏。
十一、经典业务场景与 API 选型
将本文学到的 API 映射到真实业务,便于面试与实战时快速选型。
| 业务场景 | 推荐 API / 模式 | 本文章节 |
|---|---|---|
| 登录/注册表单 Ajax 提交 | submit + preventDefault + form.elements |
§4.1、§5.5、§8.4 |
| 地址三级联动下拉 | new Option + select.add + change |
§4.1 |
| 后台表格增删行 | insertRow / deleteRow + 事件委托 |
§4.2、§9.4 |
| 订单中心 Tab 切换 | classList 排他 + .active |
§7.4 |
| 商品图懒加载 | data-src + scroll + getBoundingClientRect |
§7.1 |
| 首页 LOGO 走马灯 | scrollLeft + setInterval 或 CSS 动画 |
§7.2 |
| 课堂/年会抽签 | setInterval + Math.random |
§7.3 |
| 邮件列表选中高亮 | this + classList.toggle |
§7.5 |
| 轮播图预加载下一张 | new Image() + onload |
§4.3 |
| 动态列表删除按钮 | 父元素 click 委托 + e.target.matches |
§9.4 |
| 自定义右键菜单 | contextmenu + preventDefault + clientX/Y |
§5.5 |
| 弹窗点击遮罩关闭 | 冒泡 + target === overlay 判断 |
§5.3、§5.5、§7.7 |
| 页面初始化脚本 | DOMContentLoaded |
§8.3 |
| 搜索框 Enter 搜索 | keydown + e.key === 'Enter' |
§8.2 |
| 手风琴 FAQ 展开收起 | classList 排他 + max-height 过渡 |
§7.6 |
| 评论/发帖字数限制 | input + value.length |
§7.8 |
| 动态 Todo 列表 | 事件委托 + closest + matches |
§7.9 |
11.1 理论串讲:从用户操作到页面更新
JS 监听器 DOM 浏览器 用户 JS 监听器 DOM 浏览器 用户 点击/输入/滚动 派发 Event(捕获→目标→冒泡) 执行 addEventListener 回调 改属性/类名/innerHTML/增删节点 重绘/回流 界面更新
11.2 扩展:现代懒加载(Intersection Observer)
手动 scroll 懒加载是理解视口判断的基础;生产环境更常用 Intersection Observer(不阻塞主线程、性能更好):
javascript
var observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
var img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
});
document.querySelectorAll('img[data-src]').forEach(function (img) {
observer.observe(img);
});
代码注释:
IntersectionObserver在元素进入/离开视口时回调,无需监听scroll。isIntersecting为true表示进入视口;加载后unobserve取消观察。- 与本章 §7.1 思路一致,仅「判断进入视口」的方式更现代;可配合
loading="lazy"原生属性。
11.3 常见面试考点速记
- 三种事件绑定区别? → 内联只认首个、DOM0 覆盖、DOM2 可多个(§5.1)。
target和currentTarget? → 前者是点击源,后者是绑定监听的元素(§5.5)。- 事件委托原理? → 冒泡 + 父元素统一监听(§9.4)。
- 为什么循环里要用
this不用i? →var无块级作用域,回调执行时i已越界(§7.5)。 innerHTML与textContent? → 前者解析 HTML,后者纯文本更安全(§3.3)。
附录:与官方文档的对照阅读
| 主题 | MDN 链接 |
|---|---|
addEventListener |
https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener |
| 事件冒泡 | https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Scripting/Event_bubbling |
HTMLFormElement |
https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement |
HTMLTableElement |
https://developer.mozilla.org/en-US/docs/Web/API/HTMLTableElement |
Element.classList |
https://developer.mozilla.org/en-US/docs/Web/API/Element/classList |
getBoundingClientRect |
https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect |
KeyboardEvent |
https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent |
Intersection Observer |
https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API |
本文覆盖:元素操作回顾、HTML DOM 专有接口、事件绑定/事件流/事件对象、经典综合案例、CSS 业界对照、常用事件速查、业务场景选型与面试考点。建议将各 HTML 片段保存为独立页面,在同级 images 目录放置 db01.jpg~db10.jpg(或 .svg)后本地验证。