Skip to main content
我最早用 addEventListener 时只记住冒泡和捕获,真在滚动卡顿时加上 passive: true 才好使,也才明白它为什么能生效。target 和 currentTarget 的区别、事件委托省了什么,也是在业务里踩过才记牢。

Q1: 事件冒泡和事件捕获是什么?事件委托是什么?

1

1995年: Netscape 的捕获模型与 IE 的冒泡模型

早期浏览器对事件传播有两种截然不同的设计。Netscape 采用捕获模型(从 window 向下传播到目标),IE 采用冒泡模型(从目标向上传播到 window)。两种模型互不兼容,开发者苦不堪言。
2

致命痛点: 事件传播的混乱

如果页面上有嵌套的元素都监听了同一个事件,事件的执行顺序完全取决于浏览器的实现。开发者无法预测哪个监听器先执行,代码行为不可控。
3

W3C DOM Level 2 规范统一

W3C 在 DOM Level 2 规范中统一了两种模型:事件传播分为三个阶段——捕获阶段(从 window 向下)、目标阶段(到达目标元素)、冒泡阶段(从目标向上)。addEventListener 的第三个参数控制在哪个阶段执行。
4

事件委托: 利用冒泡的工程实践

事件委托利用冒泡机制,将子元素的事件处理逻辑绑定到父元素上。当子元素触发事件时,事件冒泡到父元素,父元素的监听器通过 event.target 判断实际触发的元素。这大幅减少了监听器数量,动态添加的子元素也能自动响应事件。
5

passive listener: 性能与功能的权衡

对于 touch/wheel 事件,浏览器在触发监听器前不知道是否会调用 preventDefault(),必须等监听器执行完才能决定是否滚动。passive: true 告诉浏览器”我不会阻止默认行为”,浏览器可以放心地在监听器执行前就开始滚动,显著提升滚动流畅度。
事件系统解决的核心问题:
DOM 事件通过捕获-目标-冒泡三个阶段统一了事件传播,事件委托利用冒泡机制实现了高效的事件管理。

没有事件委托

想象一个 1000 行的表格,每行都有点击事件。你需要给每个 <td> 绑定一个监听器——1000 个监听器占内存,动态插入新行还要重新绑定。就像一个公司里每个员工都配一个专属秘书,成本高且管理混乱。

引入事件委托

事件委托相当于在表格上只绑一个监听器,通过 event.target 判断点击了哪行。1000 行只需要 1 个监听器,动态添加的行也自动生效。就像公司只设一个前台接待,所有来访者都由前台统一登记和分发。
回答时先说事件传播的三阶段(捕获→目标→冒泡),再说事件委托的原理(利用冒泡),最后提 passive: true 的性能意义。想听的是你对事件系统设计哲学的理解,而不是背 API。

Q2: event.preventDefault() 和 event.stopPropagation() 有什么区别?

事件处理中有两个容易混淆的控制方法,本质区别在于它们控制的对象不同:
在 HTML 属性(如 onclick="return false")中,return false 等同于 preventDefault + stopPropagation。但在 addEventListener 的回调中,return false 不会阻止冒泡,也不会阻止默认行为。这是一个经典的坑。
  • HTML 属性中 return false:阻止默认 + 阻止冒泡
  • addEventListener 中 return false:什么都不阻止
preventDefault() 就像你去快递站拦住了一个包裹,不让它送到收件人手里(阻止默认行为)。但快递站的流水线还在正常运转,下一个包裹照常处理(事件继续传播)。stopPropagation() 就像你在流水线上按下了暂停键,当前包裹之后的所有包裹都停在原地不再流转(阻止事件冒泡)。但当前这个包裹本身该怎么处理还是怎么处理(默认行为不受影响)。

Q3: 什么是事件循环中的 passive event listener?

基础梳理

专家视角深度剖析

很多人只知道 passive: true “能提升性能”,但实际更在意的是:为什么 passive 能提升性能?它解决了什么浏览器层面的根本问题? 在 Chrome 的渲染流程中,滚动(scroll)是一个高度优化的操作——它由合成器线程(Compositor Thread)处理,不需要主线程参与。但问题是:当用户在触摸屏上滑动时,浏览器需要先触发 touchstart/touchmove 监听器,因为监听器可能调用 event.preventDefault() 来阻止滚动。如果监听器执行耗时,浏览器必须等它完成才能确定是否滚动——这就导致了滚动卡顿。 passive: true 的设计哲学是信任开发者:你告诉我你不会阻止默认行为,我就不用等你了,直接开始滚动。Chrome 从 v56 开始,把 document 级别的 touchstart/touchmove/wheel 事件默认设为 passive: true,这就是为什么现代浏览器的默认滚动比以前流畅得多。

Q4: document load 和 DOMContentLoaded 有什么区别?

DOMContentLoaded 的触发时机: HTML 解析完成、DOM 树构建完成时触发。不等待 CSS、图片、iframe 加载完成。 load 的触发时机: 所有资源(CSS、图片、iframe、字体)加载完成后才触发。 关键区别:
大多数业务代码应该监听 DOMContentLoaded 而不是 load——你不需要等所有图片加载完才初始化页面逻辑。load 适合需要获取图片尺寸或等待 iframe 内容的场景。在 React/Vue 中,框架的初始化代码通常在 DOMContentLoaded 时执行。

Blink 的事件分发实现在 EventDispatcher::DispatchEvent 中(详见 Blink 源码:event_dispatcher.cc):
这段代码做了 3 件事:首先构建传播路径(从目标元素向上收集所有祖先节点到 window),然后按捕获→目标→冒泡的顺序依次触发监听器。如果 stopPropagation() 被调用,propagation_stopped_ 标志被设置,后续节点跳过。 详见 Blink 源码:registered_event_listener.h:
事件监听器存储在 EventTarget 的 EventListenerMap 中,每个事件类型对应一个 EventListenerVector。passive 标志在事件分发时被检查——如果监听器是 passive 的,浏览器可以安全地在监听器执行前开始处理默认行为(如滚动)。

及格回答 vs 满分回答

问题:“说说事件冒泡和事件捕获”

及格回答(60-70分):
“事件先捕获(从 window 到目标),再冒泡(从目标到 window)。addEventListener 第三个参数控制在哪个阶段执行。事件委托利用冒泡机制。”
问题出在:知道规则但不理解设计。 满分回答(90-100分):
事件传播由 W3C DOM Level 2 规范统一为三个阶段:捕获阶段从 window 向下遍历到目标元素,目标阶段触发目标元素上的所有监听器(不区分捕获/冒泡),冒泡阶段从目标向上遍历到 window。[1]
在 Blink 中,事件分发由 EventDispatcher::DispatchEvent 实现。它先构建传播路径(从目标元素向上收集所有祖先节点),然后按阶段依次触发监听器。stopPropagation() 设置 propagation_stopped_ 标志跳过后续节点,stopImmediatePropagation() 还会跳过同一元素上的其他监听器。[2]
passive: true 的性能意义在于:对于 touch/wheel 事件,浏览器在触发监听器前不知道是否会调用 preventDefault(),必须等监听器执行完才能决定是否滚动。passive: true 告诉浏览器”我不会阻止默认行为”,浏览器可以放心地在监听器执行前就开始滚动。Chrome 从 v56 开始把 document 级别的 touch/wheel 事件默认设为 passive,这就是现代浏览器默认滚动更流畅的原因。
事件委托是利用冒泡机制的工程实践——将子元素的事件处理绑定到父元素,通过 event.target 判断实际触发的元素。优点是减少监听器数量、动态元素自动生效;缺点是不支持不冒泡的事件(如 focus/blur,可以用 focusin/focusout 替代)。React 17+ 把所有事件委托到 root 容器,而不是 document。

常见追问

题目来源于真实面经(字节/腾讯/阿里前端面试、牛客网、haizlin/fe-interview),按难度递进。
答题要点: 指出一个常见盲点:在事件委托中容易混用这两个属性。高分回答: “e.target 是实际触发事件的元素(用户真正点击的那个),e.currentTarget 是绑定事件监听器的元素。在事件委托中,两者通常不同。
一个常见错误是在事件委托中用 e.target.tagName === 'LI' 判断,但如果 li 内部有嵌套元素(如 <li><span>text</span></li>),点击 span 时 e.target 是 span 而不是 li。正确做法是用 e.target.closest('li') 向上查找最近的匹配元素。”
答题要点: 结合 React 的事件委托策略和合成事件的设计初衷解释。高分回答: “React 的合成事件(SyntheticEvent)是对原生事件的包装,提供统一的跨浏览器接口。核心区别有三点:1)委托策略:React 17+ 把所有事件委托到 root 容器(而不是 document),这使得多个 React 应用可以共存于同一页面。React 16 及之前委托到 document。 2)事件池化:React 17 之前,合成事件对象会被复用——事件回调执行完后,事件对象的属性被清空。异步访问属性会得到 null。React 17 移除了事件池化。 3)合成事件不等于原生事件:e.stopPropagation() 只阻止 React 内部的事件传播,不阻止原生事件。如果需要同时阻止,要用 e.nativeEvent.stopImmediatePropagation()。”
答题要点: 必须分场景讨论——原生 DOM 和框架中的不同处理方式。高分回答: “focus/blur 不冒泡是 W3C 规范的设计——因为焦点事件通常只关心当前获得/失去焦点的元素,不需要传播到父元素。但 focusin/focusout 会冒泡,可以作为替代。在原生 DOM 中实现 focus 事件委托有两种方式: 1)用 focusin/focusout 替代(会冒泡)。 2)在捕获阶段监听 focus:parent.addEventListener('focus', handler, true)。React 的 onFocus/onBlur 实际上用的是 focusin/focusout,所以看起来像是冒泡的。Vue 的事件系统也做了类似的适配。这是一个很好的例子,说明框架在底层做了大量的兼容性处理。”
答题要点: 结合实际工程场景解释事件委托的实现。高分回答: “实现一个通用的事件委托处理器需要考虑: 1. 选择器匹配:使用 event.target.closest(selector) 向上查找匹配的元素。 2. 动态元素支持:事件委托天然支持动态添加的元素。 3. 性能优化:避免在每次事件触发时都遍历所有子元素。
4. 注意事项:closest() 会从当前元素向上查找,直到找到匹配的元素或到达 parent。如果 parent 本身匹配 selector,需要额外检查。”
答题要点: 必须分场景讨论!CustomEvent 的适用场景。高分回答: “CustomEvent 允许开发者创建自定义的事件类型,用于组件间的通信: 1. 组件通信:在 Web Components 中,CustomEvent 用于组件向外部发送通知。 2. 插件系统:插件可以通过监听自定义事件来扩展主程序的功能。 3. 状态同步:在不同的模块之间同步状态变化。 4. 调试工具:在开发过程中,通过自定义事件记录和追踪状态变化。
答题要点: 对比常见场景,点出目标阶段的特殊性。高分回答: “分两种情况讨论:情况 1:事件源就是绑定监听器的元素(目标阶段) 捕获和冒泡的监听器按 addEventListener 的注册顺序执行,不区分 capture 参数。这是 DOM Level 3 规范的行为(旧浏览器可能先执行捕获再执行冒泡)。
情况 2:事件源是子元素(冒泡阶段) 父元素的捕获监听器先于子元素执行,子元素的冒泡监听器先于父元素的冒泡监听器执行。
”
答题要点: 结合移动端的触摸事件机制解释。高分回答: “在移动端,触摸事件的触发顺序是:touchstart → touchmove → touchend → click。 300ms 延迟的原因:浏览器需要等待 300ms 来判断用户是单击还是双击缩放。 解决方案: 1. 禁用缩放:在 <meta> 标签中设置 viewport 的 user-scalable=no,但影响无障碍访问。 2. CSS touch-action:使用 touch-action: manipulation 告诉浏览器不需要检测双击。 3. FastClick 库:在 touchend 时立即触发点击事件,不再等待 300ms。 4. 现代浏览器:Chrome 32+ 和 Safari 9.1+ 已经在支持 viewport 的页面中自动禁用 300ms 延迟。 最佳实践:使用 touch-action: manipulation + 现代浏览器,不再需要 FastClick。”
答题要点: 结合 DevTools 的内存分析工具解释。高分回答: “事件监听器的内存泄漏是一个常见问题: 1. 泄漏原因:组件销毁时未移除事件监听器,导致监听器持有对已销毁 DOM 节点的引用。 2. 排查方法:使用 Chrome DevTools 的 Memory 面板拍 Heap Snapshot,查找 Detached 开头的 DOM 节点,查看 Retainer 链找到未移除的监听器。 3. 解决方案:
  • 在 useEffect 的清理函数中移除监听器。
  • 使用 AbortController 的 signal 参数,在组件销毁时自动移除监听器。
  • 使用事件委托,减少监听器数量。
4. 注意事项:匿名函数无法被移除,必须使用具名函数或变量引用。“

延伸阅读与引用参考