Skip to content

二、DOM与事件模型

主题核心知识点概览
1.事件流:捕获与冒泡描述 DOM 的事件流建立心智模型。了解一个事件从外到内(捕获)再从内到外(冒泡)的完整路径。
2.事件委托:更高效的监听• 什么是事件委托及其优点 • event.target vs event.currentTarget核心实战技巧。基于“冒泡”原理,引出事件委托,对比为每个子元素绑定事件与在父元素上统一监听的优劣,并解释target与currentTarget的区别。
3.阻止冒泡与默认行为• event.stopPropagation() •event.preventDefault()掌握控制权。用清晰的示例分别演示如何阻止事件继续传播,以及如何阻止链接跳转、表单提交等浏览器默认行为。
4.深入 addEventListener• useCapture 和 passive 的作用性能与控制。讲解addEventListener的第三个参数。useCapture关联到捕获阶段,而passive: true则是一个重要的滚动性能优化点。

1.事件流:捕获与冒泡

1. 核心心智模型

当你在网页上点击一个元素时,这个“点击事件”并不仅仅发生在该元素上。它经历了一个完整的旅程,这个旅程被称为 DOM 事件流 (Event Flow)。

想象一下一个三层嵌套的盒子:div (外层) > p (中层) > span (内层)。当你点击最里面的 span 时:

  1. 捕获阶段 (Capturing Phase): 浏览器会从最外层的祖先 window 开始,像一个“指挥官”下达命令一样,逐级向下“探查”,经过 document<html><body>divp,最终到达你点击的目标 span。这个从外到内的过程就是捕获阶段。

  2. 目标阶段 (Target Phase): 事件到达了它的最终目的地——你实际点击的 span 元素。事件在这里被触发。

  3. 冒泡阶段 (Bubbling Phase): 事件完成任务后,开始“汇报工作”。它会从 span 开始,逐级向上“冒泡”,经过 pdiv,一直到 window。这个从内到外的过程就是冒泡阶段。

一句话总结:事件先从外到内“潜入”(捕获),到达目标后,再从内到外“浮出”(冒泡)。

默认情况下,我们用 addEventListener 添加的监听器只在 冒泡阶段 执行。

2. 示例:在控制台直观展示事件流

让我们用代码来直观地看到这个完整的路径。

HTML 结构:

html
<div class="grandparent">
  Grandparent
  <div class="parent">
    Parent
    <div class="child">Child</div>
  </div>
</div>

CSS (仅为视觉效果):

css
div {
  padding: 30px;
  border: 2px solid #666;
  text-align: center;
  font-family: sans-serif;
}
.grandparent { background-color: #fdd; }
.parent { background-color: #dfd; }
.child { background-color: #ddf; }

JavaScript 实现:

我们将为每一个 div 都添加两个监听器:

  • 一个在捕获阶段触发 ({ capture: true })

  • 一个在冒泡阶段触发 (默认)

javascript
const grandparent = document.querySelector('.grandparent');
const parent = document.querySelector('.parent');
const child = document.querySelector('.child');

// --- 捕获阶段监听器 ---
grandparent.addEventListener('click', () => {
  console.log('Grandparent - 捕获阶段');
}, { capture: true });

parent.addEventListener('click', () => {
  console.log('Parent - 捕获阶段');
}, { capture: true });

child.addEventListener('click', () => {
  console.log('Child - 捕获阶段 (也会在目标阶段触发)');
}, { capture: true });


// --- 冒泡阶段监听器 ---
grandparent.addEventListener('click', () => {
  console.log('Grandparent - 冒泡阶段');
});

parent.addEventListener('click', () => {
  console.log('Parent - 冒泡阶段');
});

child.addEventListener('click', () => {
  console.log('Child - 冒泡阶段');
});

运行与分析:

现在,打开浏览器的开发者工具,点击最内层的蓝色 Child 区域。你会在控制台看到以下输出:

text
// ↓ 从外到内(捕获阶段)
Grandparent - 捕获阶段
Parent - 捕获阶段
Child - 捕获阶段 (也会在目标阶段触发)

// ↓ 从内到外(冒泡阶段)
Child - 冒泡阶段
Parent - 冒泡阶段
Grandparent - 冒泡阶段

结果分析:

  1. 事件开始下沉: 点击发生后,事件流从 grandparent 开始,触发了它的捕获监听器。

  2. 继续下沉: 事件流到达 parent,触发了它的捕获监听器。

  3. 到达目标: 事件流到达 child。在目标元素上,监听器触发的顺序是按代码注册的顺序,所以先触发了捕获监听器,然后是冒泡监听器。

  4. 事件开始上浮: 完成目标阶段后,事件开始冒泡,从 child 上浮到 parent,触发了 parent 的冒泡监听器。

  5. 继续上浮: 事件继续上浮到 grandparent,触发了它的冒泡监听器,旅程结束。

通过这个简单的实验,事件流的完整“V”字形路径被清晰地展现在我们面前。理解这个模型是掌握事件委托、阻止事件传播等高级技巧的基础。

2.事件委托:更高效的监听

  1. 核心原理:事件冒泡 (Event Bubbling) 当你点击一个按钮时,你不仅仅是点击了那个按钮。在浏览器看来,你同时还点击了按钮所在的容器、整个页面、甚至 window

事件冒泡 指的是,当一个元素上的事件被触发时,该事件会从这个元素开始,逐级向上层父元素传播,直到 document 对象。

  • 比喻: 就像往水里扔一块石头(触发事件的元素),涟漪(事件)会从中心点不断向外扩散(向父元素传播)。
  1. 什么是事件委托?

基于事件冒泡的原理,我们不必为每个子元素都绑定事件监听器,而是只在它们的父元素上绑定一个监听器。这个父元素就像一个“受委托”的代表,统一处理所有子元素上冒泡过来的事件。

场景对比

假设我们有一个商品列表 <ul>,里面有很多 <li> 项,我们想在点击每个 <li> 时获取其内容。

  • 常规做法 (不推荐):
javascript
const items = document.querySelectorAll('ul li');
items.forEach(item => {
  item.addEventListener('click', () => {
    console.log('你点击了:', item.textContent);
  });
});
  • 缺点:

    1. 性能开销大: 如果有 1000 个 <li>,就需要创建 1000 个事件监听器,占用大量内存。

    2. 维护困难: 如果通过 JS 动态添加了一个新的 <li>,你需要手动为这个新元素再次绑定事件。

  • 事件委托 (推荐):

javascript
const list = document.querySelector('ul');
list.addEventListener('click', (event) => {
  // 只有当被点击的确实是 li 元素时才响应
  if (event.target.tagName === 'LI') {
    console.log('你点击了:', event.target.textContent);
  }
});
  • 优点:
    1. 性能好: 无论有多少子元素,都只需要在父元素上绑定一个监听器。

    2. 维护方便: 动态添加的子元素也能被监听到,因为事件会自然冒泡到父元素,而父元素的监听器一直都在。

  1. event.target vs event.currentTarget:关键区别

在处理委托事件时,正确区分 event.targetevent.currentTarget至关重要。

属性描述在委托场景中比喻
event.target事件的真正来源。它是用户实际点击的那个最具体的元素。可能是 <li>,也可能是 <li> 里的 <span><img>包裹的目标:快递员要去送一个包裹,target 就是包裹上写的具体门牌号,例如“302室”。
event.currentTarget监听器所在的元素。也就是你调用 addEventListener 的那个元素。永远是父元素 <ul>送达的大楼:快递员把车停在哪栋大楼下,这栋大楼就是 currentTarget,例如“幸福小区A栋”。

总结: 在事件委托的回调函数中,我们通常关心的是 event.target,因为它告诉我们是哪个子元素触发了事件。而 event.currentTarget 则始终是那个我们绑定事件的父元素。

3.阻止冒泡与默认行为

在事件处理中,有时我们需要更精细地控制事件的行为。浏览器为我们提供了两个强大的工具:event.stopPropagation()event.preventDefault(),它们分别用于控制事件的“传播”和“默认动作”。

1. event.stopPropagation():截断事件的传播之路

  • 功能: 阻止事件从当前元素继续向上冒泡到父元素。

  • 核心场景: 当一个元素和它的父元素都绑定了相同的事件(如 click)时,我们可能只希望触发子元素的事件,而不希望“打扰”到父元素。

  • 比喻: 事件冒泡就像水中的涟漪,会一圈圈向外扩散。event.stopPropagation() 就像在涟漪扩散的路径上立刻放置了一道屏障,让涟漪无法继续向外传播。

示例:卡片内的关闭按钮

想象一个可以点击的卡片(div.card),点击后会跳转到详情页。卡片右上角有一个关闭按钮(span.close),点击它应该只关闭卡片,而不是跳转页面。

HTML 结构:

html
<div class="card">
  我是一张卡片,点击我会跳转...
  <span class="close">×</span>
</div>

JavaScript 实现:

javascript
const card = document.querySelector('.card');
const closeBtn = document.querySelector('.close');

// 父元素:卡片的点击事件
card.addEventListener('click', () => {
  console.log('触发了卡片的点击事件,准备跳转页面!');
  // window.location.href = '/details';
});

// 子元素:关闭按钮的点击事件
closeBtn.addEventListener('click', (event) => {
  // 关键:阻止事件冒泡到父元素 card 上
  event.stopPropagation();

  console.log('触发了关闭按钮的点击事件,卡片已关闭。');
  card.style.display = 'none';
});

运行效果:

  • 如果点击卡片的空白区域: 控制台会输出 触发了卡片的点击事件,准备跳转页面!

  • 如果点击 × 关闭按钮: 因为调用了 event.stopPropagation(),事件在 <span> 处就被截断了,不会再冒泡到 <div class="card">。因此,控制台只会输出 触发了关闭按钮的点击事件,卡片已关闭。,而不会触发卡片的跳转行为。


2. event.preventDefault():取消浏览器默认行为的“常规操作”

  • 功能: 取消与特定事件关联的浏览器默认行为。

  • 核心场景: 阻止链接的默认跳转行为、阻止表单的默认提交行为等。

    示例1:阻止链接跳转

我们想点击一个链接后,不跳转页面,而是弹出一个提示框。

HTML 结构: <a href="https://google.com" id="myLink">访问谷歌</a>

JavaScript 实现:

javascript
const myLink = document.querySelector('#myLink');

myLink.addEventListener('click', (event) => {
  // 关键:阻止 <a> 标签的默认跳转行为
  event.preventDefault();

  alert('链接的默认跳转行为已被阻止!');
});

运行效果: 点击“访问谷歌”链接后,浏览器不会跳转到 Google,而是会弹出一个 alert 提示框。

示例2:自定义表单提交

在现代前端开发中,我们通常不希望表单提交导致页面刷新,而是希望通过 AJAX(如 fetch)在后台异步提交数据。

HTML 结构:

html
<form id="myForm">
  <input type="text" id="username" placeholder="输入用户名">
  <button type="submit">提交</button>
</form>

JavaScript 实现:

javascript
const myForm = document.querySelector('#myForm');

myForm.addEventListener('submit', (event) => {
  // 关键:阻止 <form> 的默认提交(页面刷新)行为
  event.preventDefault();

  const username = document.querySelector('#username').value;
  console.log(`表单的默认提交已被阻止。`);
  console.log(`正在通过 AJAX 提交用户名:${username}`);

  // 在这里可以编写 fetch(...) 代码来异步发送数据
});

运行效果: 点击“提交”按钮后,页面不会刷新。你会在控制台看到输出的信息,这为我们执行异步数据交互提供了可能。

总结对比

方法作用控制什么?何时使用?
event.stopPropagation()阻止传播控制事件在 DOM 树中的垂直流动(冒泡阶段)。防止父元素的事件处理器被触发。
event.preventDefault()阻止默认动作控制浏览器对特定元素的预设行为自定义链接点击、表单提交等行为。

4.深入 addEventListener

我们最常用的 element.addEventListener('click', handler) 写法,其实只用到了它的前两个参数。addEventListener 的第三个参数,可以极大地改变事件监听的行为,主要涉及两个关键概念:事件的“捕获”阶段和滚动性能的“被动”优化。

1. 事件的完整生命周期:捕获与冒泡

当一个事件发生时,它在 DOM 中的传播分为三个阶段:

  1. 捕获阶段 (Capturing Phase): 事件从最外层的祖先元素(window)开始,逐级“向下”传播,直到达到真正的目标元素。

  2. 目标阶段 (Target Phase): 事件到达目标元素。

  3. 冒泡阶段 (Bubbling Phase): 事件从目标元素开始,逐级“向上”冒泡,直到再次回到 window

默认情况下,所有事件监听器都在“冒泡阶段”执行。

2. useCapture: 在捕获阶段监听

addEventListener 的第三个参数,如果传入一个布尔值 true,或者一个包含 capture: true 的对象,就可以让事件监听器在捕获阶段被触发。

  • 语法:
javascript
// 传统方式
element.addEventListener('click', handler, true);

// 现代方式(推荐)
element.addEventListener('click', handler, { capture: true });
  • 作用: 允许你比目标元素更早地、在事件“下沉”的过程中就拦截到它。

  • 核心场景: 通常用于需要在事件到达最终目标前,就进行统一拦截处理的场景。虽然不常用,但在框架或大型应用的顶层事件分析、日志记录或阻止某些行为时可能有用。

示例:观察捕获与冒泡的顺序

HTML 结构:

html
<div id="parent">
  Parent
  <p id="child">Child</p>
</div>

JavaScript 实现:

javascript
const parent = document.getElementById('parent');
const child = document.getElementById('child');

// 在 Parent 上设置一个捕获阶段的监听器
parent.addEventListener('click', () => {
  console.log('1. Parent - Capturing');
}, { capture: true }); // 或直接用 true

// 在 Parent 上设置一个冒泡阶段的监听器(默认)
parent.addEventListener('click', () => {
  console.log('4. Parent - Bubbling');
}, { capture: false }); // 或不写第三个参数

// 在 Child 上设置监听器(目标阶段)
child.addEventListener('click', () => {
  console.log('2. Child - Target/Bubbling Start');
  // 注意:目标阶段的触发顺序遵循代码注册顺序
});

child.addEventListener('click', () => {
    console.log('3. Child - Also on Target');
});

运行效果:

当你点击 Child 元素时,控制台的输出顺序将是:

text
1. Parent - Capturing
2. Child - Target/Bubbling Start
3. Child - Also on Target
4. Parent - Bubbling

这个顺序清晰地展示了事件首先从 Parent 向下“捕获”,到达 Child 目标后,再从 Child 向上“冒泡”回 Parent 的完整过程。

3. passive: true: 提升滚动性能的利器

  • 问题背景: 对于 touchmove (触摸滚动) 和 wheel (滚轮滚动) 这类事件,浏览器在执行滚动操作前,会先等待你的 JavaScript 监听器执行完毕。这是因为浏览器不确定你的代码里是否会调用 event.preventDefault() 来阻止滚动。这种等待,尤其在移动端或复杂页面上,会造成明显的滚动卡顿(Jank)。

  • 解决方案: 通过设置 { passive: true },你等于是在向浏览器作出一个承诺:“我保证,这个监听器绝对不会调用 event.preventDefault() 来阻止滚动。”

  • 作用: 得到这个承诺后,浏览器就可以放心大胆地立即执行滚动,无需等待你的 JS 代码。它会在一个独立的线程里处理滚动,同时在主线程上执行你的监听器函数。这大大提升了滚动的流畅度。

  • 语法:

javascript
// 错误的做法(可能导致卡顿)
document.addEventListener('touchmove', () => {
  // 执行一些复杂的计算...
});

// 正确的性能优化做法
document.addEventListener('touchmove', () => {
  // 执行一些复杂的计算...
}, { passive: true });
  • 注意事项:
    • 这是一个重要的性能优化点,特别是对于移动 Web 应用。

    • 如果你在 { passive: true } 的监听器中尝试调用 event.preventDefault(),该调用将会被浏览器忽略,并且通常会在控制台收到一条警告。

    • 现代浏览器非常智能,对于 window, documentdocument.body 上的 touchstarttouchmove 事件,它们已经默认将 passive 设为 true

4. 总结

addEventListener 的第三个参数(选项对象)为我们提供了更深层次的控制权。

通过组合使用这些选项,可以编写出更高效、更健壮的事件处理代码。

选项作用目的
capture: true在事件的捕获阶段触发监听器。控制执行时机:更早地拦截事件。
passive: true告知浏览器监听器不会阻止默认行为。性能优化:实现流畅的页面滚动。
once: true监听器在触发一次后自动移除。代码简洁:避免手动调用 removeEventListener

element.addEventListener('scroll', handleScroll, { passive: true, once: true });

不知道说啥了很无语了