05事件基础

DOM 让 JS 能「看到」页面上每一个元素——事件则让 JS 能「听到」用户在做什么: 点击、输入、滚动、键盘、鼠标移动……前四章我们学的是「静态取元素、改属性」, 这一课起开始让页面真正活起来。addEventListener 是 DOM 事件的核心 API, 事件冒泡 / 捕获机制与事件委托则是面试高频题。

约 22 分钟 6 个动手实验 addEventListener · 委托 · 自定义事件

一、addEventListener 三参数

给元素注册监听器的标准 API 是 el.addEventListener(type, handler, options)。三个参数: type 是事件名字符串('click' / 'input' / 'keydown' 等), handler 是事件发生时被调用的函数(自动收到一个 Event 对象),options 是控制监听行为的对象或布尔。

常见事件类型速查:五大类、典型场景
分类事件名触发时机典型用法
鼠标 click / dblclick / mousedown / mouseup / mousemove / mouseenter / mouseover 点击、双击、按下/抬起、移动、进入/悬停 下拉菜单、拖拽、悬浮提示
键盘 keydown / keyup / keypress 键盘按下、抬起、按下产生字符 回车提交、快捷键、游戏控制
表单 input / change / submit / focus / blur / reset 实时输入、失焦后变化、提交、聚焦、失焦、重置 实时搜索、表单校验、登录注册
window load / resize / scroll / hashchange / popstate 页面加载完成、窗口尺寸变化、滚动、URL 哈希变化 自适应布局、回到顶部、SPA 路由
document DOMContentLoaded / visibilitychange DOM 解析完成(不必等图片)、标签页切换前后台 提早绑定事件、切后台暂停视频
onclick 属性(el.onclick = ...)也能监听点击, 但同一事件只能写一个,多个会被覆盖。addEventListener 则可以叠加多个监听器,现代项目一律用它。

二、event 对象:target vs currentTarget

handler 收到的 Event 对象里有两个最容易混的属性: event.target 是实际被点的元素, event.currentTarget 是绑监听的元素(等同于 handler 里的 this)。 因为事件会冒泡,所以二者经常不一样——这一区别是写「事件委托」的前提。

target vs currentTarget:两个关键属性对照
属性含义常见用途
event.target实际触发事件的那个元素(可能在内层)判断「用户到底点的谁」
event.currentTarget绑监听的元素(handler 内的 this)知道「当前是哪个元素在响应」
this同 currentTarget(仅普通函数,箭头函数见第六节)—
外层 div 绑 click,但点击的是内层按钮:输出对照 event.target 与 event.currentTarget。把监听改绑到内层按钮上再看输出
事件流分三阶段: 捕获阶段(从 window 一路向目标)→ 目标阶段(命中元素本身)→ 冒泡阶段(从目标一路冒回 window)。默认 addEventListener 在冒泡阶段触发,所以内层的事件会"自动传给"外层——这是事件委托能成立的根本原因。

三、stopPropagation vs preventDefault

event 对象上两个方法经常让人混淆—— stopPropagation() 阻止事件继续冒泡或捕获, preventDefault() 阻止元素的默认行为(链接跳转、表单提交等)。 它俩互不相干——一个管「事件流」,一个管「默认动作」。

一个外层 div + 内层按钮 + 一个链接:① 点按钮默认会冒泡;② 点链接默认会跳转。点两个按钮分别看到「拦冒泡」和「拦默认」的效果
别一上来就给每个监听器加 stopPropagation: 那会破坏外层组件的正常监听。 只有确认「冒泡会让外层做错事」时再用,常见于自己组件的最外层。

四、事件冒泡 vs 捕获

addEventListener 的第三个参数控制「触发时机」: false(默认)= 冒泡阶段触发, true = 捕获阶段触发。 同一元素可以两个阶段都注册, 执行顺序是:捕获 → 目标 → 冒泡(像石头落水:先沉到底、再浮上来)。

事件流三阶段 & addEventListener 第三参数
阶段走向第三参数典型用途
1. 捕获window → ... → 目标的父true(或 {capture: true})「先于子元素」做拦截(如全局调试埋点)
2. 目标命中元素本身——
3. 冒泡目标的父 → ... → windowfalse(默认)日常监听、事件委托
三层嵌套 div,三层各绑 click:外层捕获 + 中间冒泡 + 内层冒泡。点最内层,看 handler 触发顺序:祖父(capture) → 父(bubble) → 自己
DOM 早期只有「冒泡」事件流,IE 浏览器还用过「捕获先于冒泡」的逆向约定。 现代浏览器统一遵循 W3C 事件流:先捕获、后冒泡。 日常写代码默认就用冒泡,需要"先于子元素做事"时才改成 true。

五、事件委托:性能利器

冒泡机制的最大杀手锏是事件委托:只给祖先绑一个监听器, 利用冒泡捕获所有后代的事件,再用 event.target 判断「实际点的是谁」。 它有两个核心收益:① 省监听器(100 个 li 不必绑 100 个);② 支持动态元素(后加的 li 不用重新绑)。

外层 ul 绑一个监听,里面 5 个 li 谁被点都知道;按钮"动态加一条"之后,新 li 也能被识别(这正是委托的核心价值)
凡是要给列表 / 表格 / 动态生成的元素绑同一类事件, 委托就是首选。React / Vue 这类框架在源码内部也大量使用事件委托 ——把 click 实际绑到 root 节点,再根据 target 反查组件。

六、this 与箭头函数

普通函数 handler 里,this === event.currentTarget(绑监听的元素); 但箭头函数没有自己的 this,它会沿用「定义时外层」的 this。 选哪个写法要看场景:需要 this 就用普通函数;只需要 event.target 就用箭头,写法更短。

同三个按钮绑两种 handler:① 普通函数(this = currentTarget);② 箭头函数(this = 外层的 window)。点按钮分别看 this 到底是谁
经验法则:回调 / 一次性监听 → 箭头(写起来短,没歧义); 需要 this 的方法 → 普通函数(比如 Vue 2 的 methods、 React 的 class component 写法)。新版 React Hooks 用箭头也行了,所以箭头越用越多。

七、自定义事件 CustomEvent

除了浏览器原生事件,你也可以自己"造"一个事件—— 用 new CustomEvent(name, { detail }) 创建, 用 el.dispatchEvent(event) 派发, 用 addEventListener 在任何地方监听。 它最常见的舞台是组件间通信 / 跨模块解耦 ——A 模块只管 dispatch,B 模块负责 listen,相互不直接 import。

按钮 dispatcher 派发 'msg:hello' 自定义事件,另一处监听并取出 detail。你也可以加一个监听 'msg:goodbye',按钮 dispatch 的就是它
自定义事件通常要选一个「中央节点」来当巴士—— 最常见的是 document(最大、最稳),或者某棵树的根节点。 注意 event.detail 在监听者拿不到 this 时仍可读,所以 即使把 handler 写成箭头函数也没问题。

八、本章小结

  • addEventListener(type, handler, options) 是事件核心 API; 三参数依次为事件名、回调函数、监听选项({once, capture, passive})。
  • event.target 是实际触发的元素;event.currentTarget(与 this 同)是绑监听的元素——事件委托靠 target 判断真实目标。
  • 事件流三阶段:捕获 → 目标 → 冒泡; 默认 false 在冒泡阶段触发,true 是捕获。
  • stopPropagation() 阻止冒泡;preventDefault() 阻止默认行为——互不相干。
  • 事件委托:把监听绑到祖先,用 target.closest() 命中真实元素,省监听 / 支持动态元素。
  • 普通函数 handler 的 this === currentTarget;箭头函数无自己的 this,沿用外层。
  • new CustomEvent(name, {detail}) + dispatchEvent + addEventListener 实现跨模块通信雏形(组件解耦利器)。
  • 同一事件可绑多个 listener、相互不覆盖;用 removeEventListener 解绑时必须传入同一个函数引用。
动手练习: ① 用 addEventListener 给三个按钮各绑 click,把点击结果打印到 #log; ② 写一个 <ul> 含 8 个 <li>,在外层 ul 上只绑一次委托 handler, 点击时高亮当前 li 并打印它; ③ 写一个 "component:save" 自定义事件,详情带 { id, title }, 父节点 dispatch,子组件监听并 console.log 出 detail。 完成后尝试把列表改用委托、新增 li 也能响应,把子组件换成箭头函数 handler, 体会"this 与 target 各管各"的设计意图。