什么是DOM事件流

DOM事件流描述了事件在DOM树中的传播过程,包含三个阶段:

1. 捕获阶段(Capture Phase)

事件从window对象开始,沿着DOM树向下传递,直到到达目标元素的父元素。

// 设置捕获阶段的事件监听
element.addEventListener('click', handler, true) // 第三个参数为true

document.addEventListener(
  'click',
  e => {
    console.log('Document捕获阶段')
  },
  true
)

parent.addEventListener(
  'click',
  e => {
    console.log('父元素捕获阶段')
  },
  true
)

2. 目标阶段(Target Phase)

事件到达目标元素本身,触发目标元素的事件处理程序。

button.addEventListener('click', e => {
  console.log('按钮目标阶段')
  console.log(e.eventPhase) // 2 表示目标阶段
})

3. 冒泡阶段(Bubbling Phase)

事件从目标元素开始,沿着DOM树向上传递,直到到达window对象。

// 设置冒泡阶段的事件监听(默认)
element.addEventListener('click', handler, false) // 或省略第三个参数

button.addEventListener('click', e => {
  console.log('按钮冒泡阶段')
})

parent.addEventListener('click', e => {
  console.log('父元素冒泡阶段')
})

事件流完整示例

<div id="outer">
  <div id="middle">
    <button id="inner">点击我</button>
  </div>
</div>
const outer = document.getElementById('outer')
const middle = document.getElementById('middle')
const inner = document.getElementById('inner')

// 捕获阶段
outer.addEventListener('click', () => console.log('Outer 捕获'), true)
middle.addEventListener('click', () => console.log('Middle 捕获'), true)

// 冒泡阶段
inner.addEventListener('click', () => console.log('Inner 冒泡'), false)
middle.addEventListener('click', () => console.log('Middle 冒泡'), false)
outer.addEventListener('click', () => console.log('Outer 冒泡'), false)

// 点击按钮后的输出顺序:
// Outer 捕获 → Middle 捕获 → Inner 冒泡 → Middle 冒泡 → Outer 冒泡

什么是事件委托

事件委托是利用事件冒泡机制,将事件监听器绑定在父元素上,通过事件对象判断事件来源,从而处理多个子元素的同一事件。

基本原理

<ul id="todo-list">
  <li data-id="1">任务1</li>
  <li data-id="2">任务2</li>
  <li data-id="3">任务3</li>
</ul>
// 传统方式:为每个li单独绑定(不推荐)
const items = document.querySelectorAll('#todo-list li')
items.forEach(item => {
  item.addEventListener('click', e => {
    console.log('点击了:', e.target.textContent)
  })
})

// 事件委托:只绑定在父元素上(推荐)
const list = document.getElementById('todo-list')
list.addEventListener('click', e => {
  // 检查点击的是否是li元素
  if (e.target.tagName === 'LI') {
    const id = e.target.dataset.id
    console.log('点击了任务:', id)
  }
})

事件委托的优势

1. 性能优化

减少事件监听器的数量,降低内存占用。

// 假设有1000个列表项
// 传统方式:1000个监听器
listItems.forEach(item => {
  item.addEventListener('click', handleClick)
})

// 事件委托:仅1个监听器
list.addEventListener('click', e => {
  if (e.target.matches('li')) {
    handleClick(e)
  }
})

2. 动态元素自动生效

新增元素无需重新绑定事件。

const list = document.getElementById('todo-list')

list.addEventListener('click', e => {
  if (e.target.tagName === 'LI') {
    console.log('处理点击:', e.target.textContent)
  }
})

// 新增元素,自动拥有点击功能
function addTodo(text) {
  const li = document.createElement('li')
  li.textContent = text
  list.appendChild(li) // 无需重新绑定事件
}

3. 代码简洁

统一处理逻辑,易于维护。

// 统一处理列表项的各种操作
list.addEventListener('click', e => {
  const item = e.target.closest('li')
  if (!item) return

  // 删除按钮
  if (e.target.matches('.delete-btn')) {
    item.remove()
  }
  // 编辑按钮
  else if (e.target.matches('.edit-btn')) {
    editItem(item)
  }
  // 完成复选框
  else if (e.target.matches('.checkbox')) {
    toggleComplete(item)
  }
})

事件对象的核心属性

target vs currentTarget

container.addEventListener('click', e => {
  console.log('target:', e.target) // 实际点击的元素
  console.log('currentTarget:', e.currentTarget) // 绑定监听器的元素
})

区别:

  • e.target:事件触发的源头元素(最深层的元素)
  • e.currentTarget:处理事件的元素(绑定监听器的元素)

常用属性

element.addEventListener('click', e => {
  // 获取目标元素
  const target = e.target

  // 最近的匹配祖先元素
  const parent = target.closest('.item')

  // 阻止事件冒泡
  e.stopPropagation()

  // 阻止默认行为
  e.preventDefault()

  // 同时阻止冒泡和默认行为
  e.stopImmediatePropagation()

  // 获取鼠标位置
  console.log(e.clientX, e.clientY)

  // 检查鼠标按键
  console.log(e.button) // 0=左键, 1=中键, 2=右键
})

最佳实践

1. 使用事件委托的判断方法

// 方法1:tagName判断
if (e.target.tagName === 'BUTTON') {
  handleButtonClick(e.target)
}

// 方法2:classList判断
if (e.target.classList.contains('delete-btn')) {
  handleDelete(e.target)
}

// 方法3:dataset判断
if (e.target.dataset.action === 'edit') {
  handleEdit(e.target)
}

// 方法4:matches方法(推荐,支持选择器)
if (e.target.matches('[data-action]')) {
  const action = e.target.dataset.action
  handleAction(action)
}

2. 使用 closest 处理嵌套元素

<li class="todo-item">
  <div class="content">
    <span class="text">任务内容</span>
    <button class="delete-btn">删除</button>
  </div>
</li>
list.addEventListener('click', e => {
  // 向上查找最近的todo-item
  const item = e.target.closest('.todo-item')
  if (!item) return

  // 检查点击的是删除按钮
  if (e.target.matches('.delete-btn')) {
    item.remove()
  }
  // 检查点击的是文本区域
  else if (e.target.matches('.text')) {
    handleItemClick(item)
  }
})

3. 避免在捕获和冒泡阶段重复触发

// 方案1:使用事件标识
container.addEventListener(
  'click',
  e => {
    if (e.myHandled) return
    e.myHandled = true
    console.log('只处理一次')
  },
  true
)

container.addEventListener(
  'click',
  e => {
    if (e.myHandled) return
    console.log('这里不会执行')
  },
  false
)

// 方案2:使用stopPropagation(谨慎使用)
container.addEventListener(
  'click',
  e => {
    console.log('捕获阶段')
  },
  true
)

container.addEventListener(
  'click',
  e => {
    e.stopPropagation() // 阻止冒泡,但仍在目标阶段触发
    console.log('冒泡阶段')
  },
  false
)

4. 移除事件监听器

// 定义具名函数以便移除
function handleClick(e) {
  console.log('处理点击')
}

element.addEventListener('click', handleClick)

// 移除监听器
element.removeEventListener('click', handleClick)

// 使用once选项(只触发一次)
element.addEventListener(
  'click',
  () => {
    console.log('只触发一次')
  },
  { once: true }
)

5. 事件委托与React结合

function TodoList() {
  const handleListClick = e => {
    const item = e.target.closest('.todo-item')
    if (!item) return

    if (e.target.matches('.delete-btn')) {
      const id = parseInt(item.dataset.id)
      deleteTodo(id)
    }
  }

  return (
    <ul onClick={handleListClick}>
      {todos.map(todo => (
        <li className="todo-item" key={todo.id} data-id={todo.id}>
          <span>{todo.text}</span>
          <button className="delete-btn">删除</button>
        </li>
      ))}
    </ul>
  )
}

性能对比

测试场景:1000个列表项的点击事件

// 场景1:单独绑定(传统方式)
console.time('传统方式绑定')
const items = document.querySelectorAll('li')
items.forEach(item => {
  item.addEventListener('click', handleClick)
})
console.timeEnd('传统方式绑定')
// 输出:传统方式绑定: 2.156ms

// 场景2:事件委托
console.time('事件委托绑定')
list.addEventListener('click', handleDelegate)
console.timeEnd('事件委托绑定')
// 输出:事件委托绑定: 0.023ms

// 性能提升:约94倍

内存占用对比

// 传统方式:1000个监听器
// 内存占用:约80KB

// 事件委托:1个监听器
// 内存占用:约80字节

// 内存节省:约99.9%

常见应用场景

1. 表单验证

form.addEventListener('input', e => {
  const field = e.target
  if (field.matches('[required]')) {
    validateField(field)
  }
})

2. 表格操作

table.addEventListener('click', e => {
  const row = e.target.closest('tr')
  if (!row) return

  if (e.target.matches('.edit-btn')) {
    editRow(row)
  } else if (e.target.matches('.delete-btn')) {
    deleteRow(row)
  }
})

3. 标签页切换

tabs.addEventListener('click', e => {
  const tab = e.target.closest('.tab')
  if (!tab) return

  const index = Array.from(tabs.children).indexOf(tab)
  switchTab(index)
})

4. 模态框关闭

modal.addEventListener('click', e => {
  // 点击遮罩层或关闭按钮时关闭
  if (e.target === modal || e.target.matches('.close-btn')) {
    closeModal()
  }
})

常见问题

Q: 什么时候不应该使用事件委托?

A: 以下场景不适合事件委托:

  • 事件不支持冒泡(如 focusblurmouseentermouseleave
  • 需要在捕获阶段处理事件
  • 事件处理逻辑高度耦合到特定元素
  • 元素层级很深,向上查找成本高

Q: 如何处理不支持冒泡的事件?

A: 使用 focusinfocusout 替代 focusblur

// focus 不支持冒泡
input.addEventListener('focus', handler) // ❌

// focusin 支持冒泡
container.addEventListener('focusin', e => {
  // ✅
  if (e.target.matches('input')) {
    handleFocus(e.target)
  }
})

Q: 事件委托会影响性能吗?

A: 不会,反而提升性能:

  • 减少内存占用
  • 减少初始化时间
  • 动态元素无需重新绑定
  • 注意事项:避免在事件处理函数中执行复杂操作

Q: 如何调试事件委托?

A: 使用浏览器开发者工具:

// 添加调试日志
container.addEventListener('click', e => {
  console.log('事件流:', {
    target: e.target,
    currentTarget: e.currentTarget,
    phase: e.eventPhase, // 1=捕获, 2=目标, 3=冒泡
    bubbles: e.bubbles,
  })
})

总结

DOM事件流核心概念

  1. 三阶段:捕获 → 目标 → 冒泡
  2. 事件传播:从window到目标,再回到window
  3. 控制传播stopPropagation()stopImmediatePropagation()

事件委托最佳实践

  1. 优先使用事件委托:减少监听器数量
  2. 使用 closest() 查找元素:处理嵌套结构
  3. 使用 matches() 判断目标:灵活匹配选择器
  4. 注意事件阶段:捕获和冒泡的差异
  5. 合理移除监听器:避免内存泄漏

性能优化要点

  1. 减少事件监听器数量
  2. 使用事件委托处理动态元素
  3. 避免重复绑定
  4. 使用 once: true 处理一次性事件

参考资源