事件传播机制详解(附直观比喻和代码示例)

1 阅读5分钟

一、核心概念通俗解释

比喻:快递包裹的派送过程

想象你网购了一个鼠标,从仓库到你家:

事件捕获阶段(从上往下):

京东仓库(window) → 北京分拣中心(document) → 朝阳区站点(body) → 你家小区(按钮)

事件目标阶段(到达目标):

快递员把鼠标交到你手里(按钮被点击)

事件冒泡阶段(从下往上):

你签收快递(按钮) → 小区代收点 → 朝阳区站点 → 北京分拣中心 → 京东仓库

二、事件传播的3个阶段

1. 事件捕获阶段(Capture Phase)

window → document → htmlbody → 父元素 → 目标元素

方向:从上到下(外层到内层)

2. 目标阶段(Target Phase)

目标元素(被点击的元素)

3. 事件冒泡阶段(Bubble Phase)

目标元素 → 父元素 → bodyhtml → document → window

方向:从下到上(内层到外层)

三、代码示例演示

示例1:基本的事件传播

<!DOCTYPE html>
<html lang="zh-CN">
<body>
  <div id="爷爷" style="padding: 50px; background: lightblue;">
    <div id="爸爸" style="padding: 30px; background: lightgreen;">
      <button id="儿子" style="padding: 20px;">点我!</button>
    </div>
  </div>

  <script>
    // 获取元素
    const 爷爷 = document.getElementById('爷爷');
    const 爸爸 = document.getElementById('爸爸');
    const 儿子 = document.getElementById('儿子');
    
    // 默认是冒泡阶段(false)
    爷爷.addEventListener('click', () => {
      console.log('爷爷被点击了(冒泡阶段)');
    });
    
    爸爸.addEventListener('click', () => {
      console.log('爸爸被点击了(冒泡阶段)');
    });
    
    儿子.addEventListener('click', () => {
      console.log('儿子被点击了(目标阶段)');
    });
    
    // 添加捕获阶段的监听(true)
    爷爷.addEventListener('click', () => {
      console.log('爷爷被点击了(捕获阶段)');
    }, true);  // 第三个参数为 true 表示捕获阶段
    
    爸爸.addEventListener('click', () => {
      console.log('爸爸被点击了(捕获阶段)');
    }, true);
  </script>
</body>
</html>

点击按钮时的输出顺序:

爷爷被点击了(捕获阶段)  ← 从上往下
爸爸被点击了(捕获阶段)  ← 从上往下
儿子被点击了(目标阶段)  ← 到达目标
爸爸被点击了(冒泡阶段)  ← 从下往上
爷爷被点击了(冒泡阶段)  ← 从下往上

四、stopPropagation()的作用

比喻:疫情隔离

假设你家小区(按钮)有人确诊了(被点击),疫情传播路径是:

你家 → 整个小区 → 整个街道 → 整个城市

stopPropagation()的作用:在你家门口拉起警戒线,阻止疫情扩散

示例2:使用 stopPropagation()

<script>
  爷爷.addEventListener('click', (e) => {
    console.log('爷爷:我要向上汇报!');
  });
  
  爸爸.addEventListener('click', (e) => {
    console.log('爸爸:我要向上汇报!');
  });
  
  儿子.addEventListener('click', (e) => {
    console.log('儿子:我是被点击的按钮');
    e.stopPropagation();  // 🚨 关键:阻止事件继续传播!
    console.log('儿子:我不让爸爸和爷爷知道!');
  });
</script>

点击按钮时的输出:

儿子:我是被点击的按钮
儿子:我不让爸爸和爷爷知道!

解释:事件在"儿子"这里就被拦截了,不会继续冒泡到"爸爸"和"爷爷"

五、addEventListener的第三个参数

三种使用方式:

方式1:默认(冒泡阶段)

元素.addEventListener('click', 处理函数);
// 或
元素.addEventListener('click', 处理函数, false);

方式2:捕获阶段

元素.addEventListener('click', 处理函数, true);

方式3:配置对象(现代写法)

元素.addEventListener('click', 处理函数, {
  capture: true,     // 是否在捕获阶段触发
  once: true,        // 只触发一次
  passive: true      // 不会调用 preventDefault()
});

六、实际应用场景

场景1:模态框(阻止点击外部关闭)

<div class="modal-overlay" id="modal">
  <div class="modal-content">
    <h2>重要通知</h2>
    <p>这是一个模态框</p>
    <button class="close-btn">关闭</button>
  </div>
</div>

<script>
const modal = document.getElementById('modal');
const closeBtn = document.querySelector('.close-btn');

// 点击遮罩层关闭模态框
modal.addEventListener('click', (e) => {
  if (e.target === modal) {  // 点击的是遮罩层,不是内容
    closeModal();
  }
});

// 点击关闭按钮
closeBtn.addEventListener('click', (e) => {
  e.stopPropagation();  // 🚨 阻止事件冒泡到遮罩层
  closeModal();
});

function closeModal() {
  modal.style.display = 'none';
}
</script>

场景2:下拉菜单

<div class="dropdown" id="dropdown">
  <button class="dropdown-btn">选择选项</button>
  <ul class="dropdown-menu">
    <li><a href="#" class="option">选项1</a></li>
    <li><a href="#" class="option">选项2</a></li>
    <li><a href="#" class="option">选项3</a></li>
  </ul>
</div>

<script>
const dropdown = document.getElementById('dropdown');

// 点击下拉按钮
document.querySelector('.dropdown-btn').addEventListener('click', (e) => {
  e.stopPropagation();  // 阻止冒泡到document
  dropdown.classList.toggle('open');
});

// 点击菜单项
document.querySelectorAll('.option').forEach(item => {
  item.addEventListener('click', (e) => {
    e.stopPropagation();  // 阻止冒泡
    console.log('选择了:', e.target.textContent);
  });
});

// 点击页面其他地方关闭下拉菜单
document.addEventListener('click', () => {
  dropdown.classList.remove('open');
});
</script>

场景3:事件委托

<ul id="todo-list">
  <li>任务1 <button class="delete-btn">删除</button></li>
  <li>任务2 <button class="delete-btn">删除</button></li>
  <li>任务3 <button class="delete-btn">删除</button></li>
  <!-- 可能动态添加更多任务 -->
</ul>

<script>
const todoList = document.getElementById('todo-list');

// ❌ 不好的做法:给每个按钮单独加事件
// document.querySelectorAll('.delete-btn').forEach(btn => {
//   btn.addEventListener('click', () => { /* ... */ });
// });

// ✅ 好的做法:事件委托
todoList.addEventListener('click', (e) => {
  // 检查点击的是否是删除按钮
  if (e.target.classList.contains('delete-btn')) {
    e.stopPropagation();  // 阻止事件冒泡到更高层
    const li = e.target.closest('li');
    li.remove();
    console.log('删除了任务');
  }
  
  // 可以继续处理其他类型的点击
  if (e.target.tagName === 'LI') {
    console.log('点击了任务文本');
  }
});
</script>

七、事件传播可视化演示

<!DOCTYPE html>
<html>
<style>
  #家族树 { padding: 20px; }
  .代 { 
    padding: 20px; margin: 10px; 
    border: 2px solid; cursor: pointer;
  }
  #爷爷 { background: #e3f2fd; }
  #爸爸 { background: #f3e5f5; }
  #儿子 { background: #e8f5e8; }
  .console {
    background: #000; color: #0f0; 
    padding: 10px; font-family: monospace;
    height: 200px; overflow-y: auto;
  }
</style>
<body>
  <div id="家族树">
    <div id="爷爷" class="代">爷爷
      <div id="爸爸" class="代">爸爸
        <div id="儿子" class="代">儿子</div>
      </div>
    </div>
  </div>
  
  <div class="console" id="log"></div>
  
  <script>
    const 日志 = document.getElementById('log');
    function 记录(消息) {
      日志.innerHTML += 消息 + '<br>';
      日志.scrollTop = 日志.scrollHeight;
    }
    
    const 家族成员 = ['爷爷', '爸爸', '儿子'];
    
    // 为每个成员添加捕获和冒泡事件
    家族成员.forEach(成员 => {
      const 元素 = document.getElementById(成员);
      
      // 捕获阶段
      元素.addEventListener('click', (e) => {
        记录(`捕获阶段:${成员} 感受到了点击`);
      }, true);
      
      // 冒泡阶段
      元素.addEventListener('click', (e) => {
        记录(`冒泡阶段:${成员} 在向上报告`);
      }, false);
      
      // 目标阶段(只有被点击的元素)
      元素.addEventListener('click', (e) => {
        if (e.currentTarget === e.target) {
          记录(`目标阶段:${成员} 被直接点击了!`);
        }
      });
    });
    
    // 添加阻止传播按钮
    document.getElementById('儿子').addEventListener('click', (e) => {
      if (e.shiftKey) {  // 按住Shift键点击
        e.stopPropagation();
        记录('儿子:我阻止了事件传播!');
      }
    });
  </script>
</body>
</html>

八、stopPropagation()的注意事项

1. 阻止传播 ≠ 阻止默认行为

元素.addEventListener('click', (e) => {
  e.stopPropagation();   // 阻止事件冒泡
  e.preventDefault();    // 阻止默认行为(如链接跳转)
});

2. 阻止传播的副作用

<a href="https://baidu.com" id="link">
  <span id="inner">点击我</span>
</a>

<script>
// ❌ 错误的用法
document.getElementById('inner').addEventListener('click', (e) => {
  e.stopPropagation();  // 阻止事件冒泡到链接
  console.log('点击了span');
  // 但链接不会跳转!
});

// ✅ 正确的做法
document.getElementById('link').addEventListener('click', (e) => {
  console.log('链接被点击了');
  // 在这里决定是否跳转
});
</script>

九、总结表格

场景解决方法示例
阻止事件冒泡e.stopPropagation()下拉菜单点击时不关闭
事件委托在父元素监听动态列表项处理
只触发一次{ once: true }第一次点击后不再监听
只在捕获阶段触发{ capture: true }提前拦截事件
阻止默认行为e.preventDefault()阻止表单提交、链接跳转

十、一句话记忆

事件传播就像扔石头进池塘:

  • 捕获阶段:石头从上往下落(window → 目标
  • 目标阶段:石头击中水面(目标元素
  • 冒泡阶段:涟漪从中心往外扩散(目标 → window

stopPropagation() ​ 就是在水面铺一层防扩散膜,阻止涟漪继续扩散!