事件冒泡与捕获

DOM 事件的三个阶段、stopPropagation、事件委托,以及 React 合成事件机制。

#tech / dev / frontend #resource / browser #type / concept #status / growing

[!info] related notes

事件冒泡与捕获

定义

当一个事件发生在 DOM 元素上时,它不是只在该元素上触发,而是按照特定路径在 DOM 树上传播。这个传播过程分为三个阶段:捕获阶段、目标阶段、冒泡阶段。

三个阶段

  1. 捕获阶段:事件从 window 向下传播到目标元素(window → document → html → body → ...
  2. 目标阶段:事件到达目标元素本身
  3. 冒泡阶段:事件从目标元素向上传播回 window... → body → html → document → window

默认情况下,事件监听器在冒泡阶段触发。

基本示例

<div id="parent">
  <button id="child">点击</button>
</div>
const parent = document.getElementById('parent')
const child = document.getElementById('child')

// 默认:冒泡阶段触发
parent.addEventListener('click', () => console.log('parent bubble'))
child.addEventListener('click', () => console.log('child'))

// 输出(点击 child): child, parent bubble

捕获阶段监听

addEventListener 的第三个参数设为 true 表示在捕获阶段触发:

parent.addEventListener('click', () => console.log('parent capture'), true)
child.addEventListener('click', () => console.log('child'))

// 输出(点击 child): parent capture, child

第三个参数也可以传对象:

parent.addEventListener('click', () => console.log('parent capture'), {
  capture: true
})

// 更多选项
parent.addEventListener('click', handler, {
  capture: true,   // 捕获阶段
  once: true,      // 只触发一次
  passive: true,   // 不会调用 preventDefault(优化滚动性能)
  signal: abortController.signal  // 用于取消监听
})

stopPropagation

阻止事件继续传播:

child.addEventListener('click', (e) => {
  e.stopPropagation()  // 阻止冒泡
  console.log('child only')
})

parent.addEventListener('click', () => {
  console.log('parent')  // 不会执行
})

stopImmediatePropagation 更强:不仅阻止传播,还阻止同一元素上的其他监听器执行:

child.addEventListener('click', (e) => {
  e.stopImmediatePropagation()
  console.log('first handler')
})

child.addEventListener('click', () => {
  console.log('second handler')  // 不会执行
})

preventDefault

阻止浏览器的默认行为(不阻止传播):链接跳转、表单提交、右键菜单等。用法:e.preventDefault()。注意不是所有事件都可阻止,用 e.cancelable 检查。

事件委托

利用冒泡机制,把事件监听器绑定在父元素上,通过 e.target 判断实际触发元素。

<ul id="todo-list">
  <li>任务 1</li>
  <li>任务 2</li>
  <li>任务 3</li>
</ul>
// 事件委托:一个监听器处理所有 li 的点击
document.getElementById('todo-list').addEventListener('click', (e) => {
  if (e.target.tagName === 'LI') {
    console.log('点击了:', e.target.textContent)
    e.target.classList.toggle('done')
  }
})

// 动态添加的 li 也能自动被处理
const newLi = document.createElement('li')
newLi.textContent = '新任务'
document.getElementById('todo-list').appendChild(newLi)

事件委托的优势

  • 只绑定一个监听器,减少内存占用
  • 动态添加的子元素自动被处理
  • 不需要在元素销毁时手动移除监听器

使用 closest 处理嵌套元素

如果 li 内部有子元素,e.target 可能不是 li,用 e.target.closest('li') 向上查找最近匹配的祖先。

React 合成事件(Synthetic Events)

React 不直接使用原生 DOM 事件,而是在根节点上通过事件委托统一处理。

React 17 之前

所有事件委托在 document 上。

React 17 及之后

所有事件委托在 React 根节点(root 容器)上,避免与多个 React 实例冲突。

function Button() {
  const handleClick = (e: React.MouseEvent) => {
    e.stopPropagation() // 阻止 React 事件树中的冒泡
    // 访问原生事件:e.nativeEvent
  }
  return <button onClick={handleClick}>点击</button>
}

React 合成事件的特点

  • 跨浏览器统一行为
  • 事件池化(React 17 之前,事件对象会被复用)
  • 使用驼峰命名(onClick 而不是 onclick
  • e.stopPropagation() 阻止的是 React 事件树的冒泡,不是原生 DOM 的冒泡

被动事件监听器(Passive)

{ passive: true } 告诉浏览器不会调用 preventDefault,允许优化滚动性能。Chrome 自动将 touchstarttouchmovewheel 视为 passive。

边界

  • focusblurmouseentermouseleave 不冒泡
  • focusin/focusout 是冒泡版的 focus/blur
  • mouseenter/mouseleave 不冒泡,但 mouseover/mouseout 会冒泡
  • e.target 是触发事件的元素,e.currentTarget 是绑定监听器的元素
  • 不是所有事件都可以被 preventDefault,可以用 e.cancelable 检查

一句话记忆法

事件先捕获(向下)再冒泡(向上);stopPropagation 阻止传播,preventDefault 阻止默认行为;事件委托利用冒泡在父元素上处理子元素事件。

创建于 2026/5/27 更新于 2026/5/27