DAY_14JavaScript DOM 进阶:HTML DOM 接口、事件监听与经典交互实战

从「能改页面」到「能写交互」------本文系统梳理 HTML 专有 DOM 接口、三种事件绑定方式、事件流机制,以及图片懒加载、无缝滚动、选项卡等生产级场景的实现思路。内容对齐 MDN EventTargetHTMLFormElementHTMLTableElement 等官方文档,并补充业界常见写法与归纳总结。

📌 图片资源说明

本文所有案例代码引用的图片路径均为 images/db01.jpg ~ images/db10.jpg。运行案例前需要:

  1. 确保 images 目录存在 --- 本目录下已有 images/ 文件夹
  2. 准备图片文件 --- 支持 db01.jpgdb10.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 事件对象:targetpreventDefaultstopPropagation](#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)
  • 七、经典综合案例(完整可运行)
    • [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 标签(如 formtable)扩展的专有接口 比通用 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) 监听器第一个参数,描述事件详情 typetargetpreventDefault
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 只读计算样式;classListclassName 更适合增删切换类名。

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() 方法 重置为默认值

文本类控件inputtextarea):

方法 说明
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.lengthform.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 事件,直接提交(常用于绕过校验时需自行处理)

监听 submitpreventDefault() 是 Ajax 表单(如 Element Plus、Ant Design Form)的底层思路之一。


4.2 表格相关 API

名词:HTMLTableElement / HTMLTableRowElement / HTMLTableCellElement
对象 常用 API
table rowsinsertRow(index)deleteRow(index)
tr rowIndexcellsinsertCell(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 事件模型,一次点击会经历:

  1. 捕获阶段windowdocument → ... → 父元素 → 目标元素
  2. 目标阶段:到达目标
  3. 冒泡阶段 :目标 → 父元素 → ... → 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 事件对象:targetpreventDefaultstopPropagation

监听器会收到事件对象 作为第一个参数(通常命名为 evente),是处理交互细节的核心。

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 用于点击空白处关闭菜单;实际项目可用「遮罩层」优化体验。

经典场景 :自定义右键菜单、拖拽时禁止选中文字、表单 submitpreventDefault 做 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 包含 paddingborder,不再向外膨胀
默认 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,再给当前项加上。
  • tabNavItemstabContentItems 通过 forEachindex 一一对应。
  • .tab-content li 默认 display: none.activedisplay: 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,会失效。
  • 正确 :回调内用 thisthis 指向被点击的 <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 弹窗是 stopPropagationevent.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 键抬起 常用于组合键释放检测

keydownkeypress 的区别

  • keydown:任意键都会触发,包括 Shift、方向键、F1~F12。
  • keypress:仅当按键能产生「字符」时触发(如字母、数字),对功能键无效。
  • 实际开发优先使用 keydown ,用 event.keyevent.code 判断按键(MDN KeyboardEvent)。

哪些元素可以监听键盘事件?

必须是可获得焦点 的元素:inputtextareaselectbutton<a href>、以及设置了 tabindex="0" 的任意元素。div 默认不能打字,需 tabindexfocus() 后才能接收键盘事件。

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 为键名(如 EnterArrowDown);e.code 为物理键位(如 KeyA),布局无关时更稳定。
  • inputEnter 常配合 preventDefault 阻止意外提交。
  • 非表单元素需 tabindex="0" 才能被 Tab 聚焦并接收键盘事件。

经典场景:搜索框回车提交、快捷键(Ctrl+S)、游戏方向键、Esc 关闭弹窗、输入框限制只能输入数字。

8.3 文档事件

事件 说明
DOMContentLoaded HTML 解析完成,不等待图片、样式表、子框架
load 页面及所有依赖资源(图、样式、iframe)加载完成
beforeunload 即将离开页面,可弹确认框(需用户交互过)
unload 文档正在卸载(现代更推荐 pagehide

loadDOMContentLoaded 的区别

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
懒加载 scrollgetBoundingClientRectdataset flex-wrap
无缝滚动 scrollLeftsetInterval flexoverflow:hidden
点名器 setInterval / clearInterval 字号行高
选项卡 classList、排他 .activeflex
列表高亮 thisclassList.toggle .active
动态表格 insertRowdeleteRow table-layoutborder-collapse
表单 form.elementsOption 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 与排他思想
综合案例实战
事件对象 + 委托 + 性能

最佳实践清单

  1. 优先 addEventListener,避免内联 onclick
  2. 动态列表用事件委托,减少监听器数量。
  3. 需要解绑时保存具名函数引用,便于 removeEventListener
  4. 图片场景掌握 data-src 懒加载思路;新项目可结合 loading="lazy"
  5. 操作表格行注意 rowIndexthead 对索引的影响。
  6. 在循环中绑定事件,用 thisforEach 闭包,避免 var i 陷阱。
  7. 组件卸载/路由离开时移除全局 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
  • isIntersectingtrue 表示进入视口;加载后 unobserve 取消观察。
  • 与本章 §7.1 思路一致,仅「判断进入视口」的方式更现代;可配合 loading="lazy" 原生属性。

11.3 常见面试考点速记

  1. 三种事件绑定区别? → 内联只认首个、DOM0 覆盖、DOM2 可多个(§5.1)。
  2. targetcurrentTarget → 前者是点击源,后者是绑定监听的元素(§5.5)。
  3. 事件委托原理? → 冒泡 + 父元素统一监听(§9.4)。
  4. 为什么循环里要用 this 不用 ivar 无块级作用域,回调执行时 i 已越界(§7.5)。
  5. innerHTMLtextContent → 前者解析 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.jpgdb10.jpg(或 .svg)后本地验证。

相关推荐
江南十四行1 小时前
从Web开发到网络通信的知识梳理
前端
肖老师xy1 小时前
Vue3+OpenStreetMap实现地理围栏
前端
笨蛋不要掉眼泪1 小时前
Java并发编程:深入理解ThreadLocal
java·开发语言·jvm·并发
番茄去哪了1 小时前
JVM虚拟机(中)
java·开发语言·jvm
KaMeidebaby1 小时前
卡梅德生物技术快报|Fab 抗体文库构建标准化实验流程与数据复盘
服务器·前端·数据库·人工智能·算法
程序猿乐锅1 小时前
【Tilas|第十篇】万字讲解SpringAOP知识点
java·开发语言·idea·tlias
W.W.H.1 小时前
Qt 应用防多开:极简单例方案
开发语言·qt·单例模式·共享内存
暗冰ཏོ1 小时前
React超详细学习指南
前端·react.js·前端框架
枫叶v.1 小时前
Scrapling 入门:一个现代 Python 网页采集框架
开发语言·python