前端事件传播

DOM 事件在捕获、目标和冒泡三个阶段中的传播过程,以及事件委托、传播控制与常见面试问法。

#tech / dev / frontend #type / concept #status / evergreen

[!info] related notes

前端事件传播

DOM 事件不是只在一个节点上孤立执行,它通常会沿着节点树传播。浏览器会让事件在多个相关节点之间按顺序流动,这就是事件传播机制。

一句话定义

一个 DOM 事件通常会经历捕获阶段、目标阶段和冒泡阶段,浏览器靠这套机制让目标元素与祖先元素都能参与事件处理。

为什么会有事件传播

如果你点击一个按钮,而按钮在 div 里,div 又在 body 里,浏览器至少要回答两件事:

  1. 真正触发事件的是谁
  2. 目标元素之外的父层要不要也有机会响应

所以浏览器设计了完整传播链:

  • 先从外往内找目标,这是捕获
  • 到达真正触发节点,这是目标阶段
  • 再从内往外通知祖先,这是冒泡

三个阶段

捕获阶段

事件从更外层祖先节点一层层向目标节点靠近。

典型顺序可以理解为:

window -> document -> html -> body -> ... -> target

如果监听器注册为捕获型,它会在这一阶段先执行。

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

目标阶段

事件真正到达触发它的元素。

要注意两点:

  • 目标阶段不是“只执行一次”
  • 目标元素上既可能有捕获监听器,也可能有冒泡监听器

冒泡阶段

事件从目标节点开始,再一层层向外层祖先节点返回。

默认 addEventListener() 注册的是冒泡监听器,所以平时最常看到的是这一层。

parent.addEventListener('click', () => {
  console.log('parent bubble')
})

一个完整例子

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

outer.addEventListener('click', () => console.log('outer bubble'))
outer.addEventListener('click', () => console.log('outer capture'), true)

inner.addEventListener('click', () => console.log('inner bubble'))
inner.addEventListener('click', () => console.log('inner capture'), true)

btn.addEventListener('click', () => console.log('btn bubble'))
btn.addEventListener('click', () => console.log('btn capture'), true)

点击按钮时,常见输出顺序是:

outer capture
inner capture
btn capture
btn bubble
inner bubble
outer bubble

最短记忆:先一路向里,再一路向外。

事件对象里最重要的几个属性

event.target

真正触发事件的元素。

如果监听器绑在父元素 ul 上,但你点击的是子元素 li,那 event.target 仍然是 li

event.currentTarget

当前正在执行监听器的元素。

ul.addEventListener('click', (event) => {
  console.log('target:', event.target)
  console.log('currentTarget:', event.currentTarget)
})

如果监听器绑在 ul 上:

  • target 是实际点击的元素
  • currentTarget 是当前处理监听器的 ul

event.eventPhase

表示当前事件处于哪个阶段。

  • 1:捕获阶段
  • 2:目标阶段
  • 3:冒泡阶段

addEventListener() 和传播阶段的关系

element.addEventListener('click', handler)
element.addEventListener('click', handler, true)
element.addEventListener('click', handler, { capture: true })
  • 默认不传第三个参数时,监听器注册在冒泡阶段
  • true{ capture: true } 时,注册在捕获阶段

如果要系统理解这个 API,再看 add-event-listener

哪些事件会冒泡,哪些默认不会

常见会冒泡的事件

  • click
  • mousedown
  • mouseup
  • keydown
  • keyup
  • input
  • change

常见默认不冒泡的事件

  • focus
  • blur
  • mouseenter
  • mouseleave

但要知道一些替代事件会冒泡:

  • focusin / focusout
  • mouseover / mouseout

事件委托为什么能成立

因为子元素触发的事件会冒泡到父元素,所以父元素可以统一监听,再根据事件来源判断到底是哪个子元素触发了动作。

<ul id="list">
  <li data-id="1">A</li>
  <li data-id="2">B</li>
  <li data-id="3">C</li>
</ul>
const list = document.getElementById('list')

list.addEventListener('click', (event) => {
  const li = event.target.closest('li')
  if (!li) return

  console.log('点击了', li.dataset.id)
})

事件委托的典型价值

  • 减少大量子节点重复绑定监听器
  • 动态新增子节点时,不必重新逐个绑定
  • 更适合列表、菜单、表格等场景
  • 逻辑集中,更容易维护

冒泡和事件委托不是一回事

  • 冒泡是浏览器的传播机制
  • 事件委托是开发者基于冒泡实现的编码策略

控制传播时最容易混淆的三个 API

stopPropagation()

阻止事件继续向父级传播,但不会阻止当前元素上的其他同类型监听器执行。

stopImmediatePropagation()

更强。它既阻止继续传播,也阻止当前元素后续同类型监听器继续执行。

preventDefault()

阻止默认行为,不是阻止传播。

例如:

  • 点击 <a> 不跳转
  • 提交表单不刷新

最短区分:

  • preventDefault() 管默认行为
  • stopPropagation() 管传播路径

实际开发中的常见场景

列表点击高亮

把监听绑在父容器上,通过 targetclosest() 找到具体项。

表格行操作

整张表统一监听,减少大量按钮绑定。

动态内容区域

弹窗、评论列表、异步渲染内容,不需要每次重新逐个绑定事件。

页面级关闭逻辑

比如点击弹窗外区域关闭弹窗,常依赖传播链和目标判断完成。

常见误区

  • 以为事件只在目标节点执行一次
  • 把冒泡和事件委托当成同一个概念
  • 认为所有事件都会冒泡
  • preventDefault()stopPropagation() 混为一谈
  • 误以为 event.target 就是绑定监听器的元素
  • 一看到联动异常就盲目 stopPropagation(),导致外层交互能力被意外切断

面试怎么讲

最稳的答法:

DOM 事件传播分为捕获、目标、冒泡三个阶段。默认 addEventListener 监听的是冒泡阶段。事件委托本质上是利用冒泡,把对子节点的监听统一交给父节点处理。另外要区分 event.targetevent.currentTarget,以及 preventDefault()stopPropagation() 的作用差异。

测试题

基础题:点击子元素,父元素为什么也响应

<div id="parent">
  <button id="child">按钮</button>
</div>
parent.addEventListener('click', () => {
  console.log('parent')
})

child.addEventListener('click', () => {
  console.log('child')
})

点击按钮后输出:

child
parent

原因是目标节点先触发,然后事件继续冒泡到父元素。

三层嵌套输出题

<div id="outer">
  <div id="middle">
    <button id="inner">click</button>
  </div>
</div>
outer.addEventListener('click', () => {
  console.log('outer bubble')
})

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

middle.addEventListener('click', () => {
  console.log('middle bubble')
})

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

inner.addEventListener('click', () => {
  console.log('inner bubble')
})

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

点击 inner 后的典型输出顺序:

outer capture
middle capture
inner capture
inner bubble
middle bubble
outer bubble

辨析题:targetcurrentTarget 的区别

参考答案:

  • target 是真正触发事件的元素
  • currentTarget 是当前正在执行监听器的元素

辨析题:preventDefault()stopPropagation() 的区别

参考答案:

  • preventDefault():阻止默认行为
  • stopPropagation():阻止继续传播

总结

事件传播是 DOM 事件系统的核心机制,分为捕获、目标、冒泡三个阶段。默认事件监听多发生在冒泡阶段,而事件委托正是基于冒泡实现的。理解传播顺序、target/currentTarget、默认行为与传播控制的区别,是掌握前端交互和面试答题的基础。

面试要点

来自 event-bubbling-capturing-delegation-interview-question 的面试视角整理。

一句话回答

DOM 事件通常会经历捕获、目标、冒泡三个阶段,而事件委托就是利用冒泡机制,把多个子元素的事件统一交给父元素处理。

面试里最稳的标准答法

事件传播通常分三步:

  1. 捕获阶段:事件从外层祖先节点往目标节点走
  2. 目标阶段:事件真正到达触发节点
  3. 冒泡阶段:事件再从目标节点往外层祖先节点返回

事件委托就是利用冒泡,把本来应该绑在多个子元素上的事件,统一绑到父元素上,再通过事件对象判断具体点中了哪个子元素。

它的价值主要有三个:

  • 减少大量重复监听器
  • 动态新增节点时不需要重新逐个绑定
  • 列表、菜单、表格这类结构更容易统一管理交互逻辑

三个概念分别怎么讲

事件捕获

  • 事件从 windowdocumenthtmlbody 这些更外层节点,逐层向目标节点传播
  • 如果监听器注册在捕获阶段,它会在“到达目标前”先执行
parent.addEventListener('click', () => {
  console.log('capture')
}, true)

第三个参数传 true,或者传 { capture: true },就表示在捕获阶段监听。

事件冒泡

  • 事件在目标节点触发后,会从内向外一层层传播
  • 这是最常见的默认事件传播方式
  • 事件委托成立,主要就靠这一层
child.addEventListener('click', () => {
  console.log('child')
})

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

child 时,通常会先输出 child,再输出 parent

事件委托

  • 把监听器绑在共同父元素上
  • 子元素点击后,事件冒泡到父元素
  • 父元素通过事件对象判断真实触发源

这是“事件传播机制”的应用方式,不是一个独立于冒泡之外的新机制。

多个按钮统一绑定点击事件,如何判断点的是哪个

这是事件委托最常见的实战题。

最基本写法:看 event.target

<div id="actions">
  <button data-action="edit">编辑</button>
  <button data-action="delete">删除</button>
  <button data-action="share">分享</button>
</div>
const actions = document.getElementById('actions')

actions.addEventListener('click', (event) => {
  const target = event.target

  if (target.tagName === 'BUTTON') {
    console.log('点击的是', target.dataset.action)
  }
})

这里判断的是:

  • 真实触发事件的节点是不是 button
  • 如果是,再通过 data-action 判断按钮身份

更稳的写法:用 closest

实际开发里,按钮内部经常还有图标、span、文本容器。如果你只判断 event.target,点到按钮里面的 span 时,target 可能不是按钮本身。

所以更稳的写法是:

<div id="actions">
  <button data-action="edit"><span>编辑</span></button>
  <button data-action="delete"><span>删除</span></button>
  <button data-action="share"><span>分享</span></button>
</div>
const actions = document.getElementById('actions')

actions.addEventListener('click', (event) => {
  const button = event.target.closest('button')

  if (!button || !actions.contains(button)) return

  const action = button.dataset.action

  if (action === 'edit') {
    console.log('编辑')
  }

  if (action === 'delete') {
    console.log('删除')
  }

  if (action === 'share') {
    console.log('分享')
  }
})

这才是更接近工程场景的答案。

为什么 closest 更好

  • 你点到按钮里的图标、文字节点时,也能向上找到真正的按钮元素
  • 不容易因为 DOM 结构多包了一层就失效
  • 更适合列表项、表格行、菜单项这类复杂节点

closest(selector) 方法会从当前元素(在这个场景中是 event.target)开始,沿着 DOM 树向上逐级寻找(包括当前元素自身),直到找到第一个匹配传入的 CSS 选择器(比如 'button')的祖先元素。

  • 如果找到了:返回那个匹配的 DOM 元素。
  • 如果没找到(一直找到 DOM 树的最顶端也没找到):返回 null

targetcurrentTargetthis 分别是什么

这是事件委托题里最容易追问的一组概念。

event.target

  • 真正触发事件的那个节点
  • 例如你点的是按钮里的 span,那它可能就是 span

event.currentTarget

  • 当前正在执行这个监听器的节点
  • 如果监听器绑在父元素上,那它就是父元素
actions.addEventListener('click', (event) => {
  console.log('target:', event.target)
  console.log('currentTarget:', event.currentTarget)
})

如果监听器绑在 #actions 上:

  • event.currentTarget 一定是 #actions
  • event.target 可能是 button,也可能是按钮里的 span

面试里一句话区分

  • target 是“谁触发的”
  • currentTarget 是“谁在处理这个事件”

一个完整传播例子

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

outer.addEventListener('click', () => console.log('outer capture'), true)
inner.addEventListener('click', () => console.log('inner capture'), true)
btn.addEventListener('click', () => console.log('btn click'))
inner.addEventListener('click', () => console.log('inner bubble'))
outer.addEventListener('click', () => console.log('outer bubble'))

点击按钮时,典型输出顺序是:

  1. outer capture
  2. inner capture
  3. btn click
  4. inner bubble
  5. outer bubble

这就把“先捕获,再到目标,再冒泡”讲完整了。

常见追问

为什么事件委托更省

因为不需要给每个子节点都单独绑一个监听器,节点很多时能减少监听器数量,也方便统一维护。

为什么动态节点适合事件委托

因为监听器绑在父元素上,新插入的子节点只要符合选择条件,就天然能被同一个监听器处理。

stopPropagation() 会影响什么

它会阻止事件继续传播,所以可能让父元素的委托逻辑收不到事件。

button.addEventListener('click', (event) => {
  event.stopPropagation()
})

如果子元素提前阻止冒泡,父元素上的委托点击可能直接失效。

所有事件都适合委托吗

不一定。要看事件是否冒泡。

  • click 通常适合委托
  • 有些事件默认不冒泡,不能直接按 click 的思路套

常见误区

  • 把捕获和冒泡理解成“只会发生其中一个”,实际上完整传播通常是两者都会经历
  • 以为事件委托只要看 event.target 就够了,复杂结构里更稳的是 closest
  • 混淆 targetcurrentTarget
  • 一旦逻辑串了就盲目 stopPropagation(),结果把父层委托逻辑也切断了
  • 以为事件委托只是为了性能,它同样是处理动态节点的好办法

最适合背诵的短答案

DOM 事件一般会经历捕获、目标、冒泡三个阶段。事件委托本质上是利用冒泡,把多个子元素的事件统一交给父元素处理,再通过 event.targettarget.closest() 判断具体点中了哪个元素。实际开发里,如果一个父容器下面有很多按钮,通常会给父容器绑一个点击事件,然后通过 data-*closest() 判断点击的是哪个按钮。

最短记忆方式

先向内捕获,到目标后再向外冒泡;事件委托就是父元素借冒泡统一处理子元素事件。

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