事件流是什么?什么是事件委托?
一句话回答
DOM 事件的传播分三个阶段:捕获(从 window 向下传到目标元素)、目标阶段、冒泡(从目标元素向上传回 window)。事件委托就是利用冒泡,把监听器绑在父元素上统一处理子元素的事件,再用 event.target.closest() 找到真正要处理的子元素。这样监听器数量少,动态添加的子元素也自动生效。
详细解析
事件流的三个阶段
以点击页面上的按钮为例,事件先沿着 window → document → <html> → <body> → … → 按钮的路径向下传递,在按钮上触发后,再沿原路向上传回 window。
addEventListener 默认在冒泡阶段触发。第三个参数可以是布尔值 useCapture,也可以是选项对象:
capture:为true时在捕获阶段触发once:触发一次后自动移除passive:声明监听器不会调用preventDefault(),见下文signal:传入AbortSignal,调用abort()时自动移除监听器
<div id="outer">
<button id="inner">点我</button>
</div>
const outer = document.getElementById('outer')
const inner = document.getElementById('inner')
outer.addEventListener('click', (e) => console.log('outer 捕获', e.eventPhase), true)
outer.addEventListener('click', (e) => console.log('outer 冒泡', e.eventPhase))
inner.addEventListener('click', (e) => console.log('inner', e.eventPhase))
点击按钮,依次输出 outer 捕获 1、inner 2、outer 冒泡 3。eventPhase 的 1、2、3 分别对应捕获、目标、冒泡阶段。
target 和 currentTarget
event.target:真正触发事件的元素,上例中始终是按钮event.currentTarget:当前正在执行的监听器绑定在哪个元素上,上例中outer的两个监听器里都是outer
阻止传播和默认行为
| 方法 | 作用 |
|---|---|
stopPropagation() |
阻止事件继续传播到其他元素,当前元素上的其他监听器仍会执行 |
stopImmediatePropagation() |
同样阻止传播,并且当前元素上后续的监听器也不再执行 |
preventDefault() |
阻止默认行为(链接跳转、表单提交、勾选复选框等),不影响传播 |
事件委托
<ul id="todo-list">
<li data-id="1">写周报 <button class="delete-btn">删除</button></li>
<li data-id="2">修复登录 bug <button class="delete-btn">删除</button></li>
</ul>
const list = document.getElementById('todo-list')
list.addEventListener('click', (e) => {
// 点到的可能是按钮里的子元素(比如图标),用 closest 向上找到按钮本身
const btn = e.target.closest('.delete-btn')
// 没找到,或者找到的元素不在列表内,就不处理
if (!btn || !list.contains(btn)) return
const item = btn.closest('li')
console.log('删除', item.dataset.id)
item.remove()
})
// 后添加的项不用单独绑定监听器,点击删除照样生效
list.insertAdjacentHTML('beforeend', '<li data-id="3">新任务 <button class="delete-btn">删除</button></li>')
好处有两个:只需要一个监听器,内存占用少、初始化快;动态增删的子元素不用重新绑定或解绑。
不冒泡的事件
focus / blur、mouseenter / mouseleave、元素自身的 scroll 等事件不会冒泡,没法直接委托。前两组可以改用会冒泡的 focusin / focusout、mouseover / mouseout。
passive 与滚动性能
处理 touchmove、wheel 这类事件时,如果有非 passive 的监听器,浏览器要先等它执行完,才知道它有没有调用 preventDefault() 取消滚动;主线程一忙,滚动就会卡顿。设置 passive: true 等于提前声明"不会阻止滚动",浏览器可以立即开始滚动,不必等监听器执行完(滚动由合成线程处理,见浏览器的多进程架构)。
el.addEventListener('touchmove', onTouchMove, { passive: true })
Chrome 等浏览器对绑定在 window、document、<html>、<body> 上的 touchstart、touchmove、wheel 监听默认按 passive 处理(DOM 规范也是这样规定的)。在这些监听器里调用 preventDefault() 会被忽略,控制台会给出警告;确实要阻止滚动时,需要显式传入 { passive: false }。
面试官可能追问
React 的事件也是委托的吗?
是的。对大部分事件,React 不会在每个 DOM 元素上绑定监听器,而是在顶层统一监听,事件触发后根据 event.target 找到对应的组件,模拟捕获和冒泡,依次调用 onClick 等处理函数。React 17 之前统一绑在 document 上,React 17 起改为绑在渲染的根容器上。这样一个页面里嵌入多个 React 应用、或者和其他技术栈混用时,stopPropagation() 的效果更符合预期。
事件委托有什么局限?
- 不冒泡的事件不能直接委托,要换成会冒泡的替代事件,或者在父元素上用捕获阶段监听(不冒泡的事件同样会经历捕获阶段)
- 中间的元素调用了
stopPropagation(),父元素就收不到事件 - 委托的层级太高(比如全都挂在
document上),页面上的每次事件都要执行判断逻辑,mousemove这类高频事件尤其要注意
如何同时阻止冒泡和默认行为?
分别调用 e.stopPropagation() 和 e.preventDefault()。return false 的效果要看场景:在 addEventListener 的监听器里没有任何作用;在 onclick 这类事件属性里只相当于 preventDefault();在 jQuery 的事件处理函数里则会同时阻止两者。Vue 中可以直接写 @click.stop.prevent。
易错点
- 委托时直接比较
e.target容易出错:点到按钮里的图标时,target是图标,要用closest()向上查找 event.currentTarget只在事件分发期间有值,在异步回调里读取会得到nullpreventDefault()不会阻止冒泡,stopPropagation()也不会阻止默认行为
AI 模拟面试官
用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。