一 、事件的综述
首先需要了解几个术语:
- 宿主环境:将js引擎作为一个组件包含在内,并且为它提供运行所需的资源的外部系统。
就是说,宿主环境提供了所有的资源,比如网络 文件 渲染 各种功能接口等等,没有了宿主环境, js引擎就是光杆司令,它就只能空转,做不了任何事情。
- 宿主对象:所有不是由 JS 语言本身定义的、而是由环境提供的对象/功能/api,都叫宿主对象。
比如 Fetch document XMLHttpRequest 等等 由宿主环境提供的都叫宿主对象。
而js语言定义的对象,比如 Array, Object, Promise, Math, Map 等等,是js的原生内置对象。
我们常说的 事件循环 任务队列 都是由宿主环境提供并且管理的。
说 js能怎样怎样,实际上是 js语言的能力+宿主环境赋予的能力 。
- 事件的核心定义:
JavaScript 中的事件是宿主环境提供的一套标准化的异步消息分发机制,是系统内发生的、可被代码侦测到的“发生”或“信号” 。
事件是一种能力,那么是不是所有的对象 所有的元素 所有的节点,都具备事件的能力呢?
并不是所有, 这里就要提到一切的源头 事件目标 EventTarget 。
EventTarget是一个接口 是一个对象 ,你要具备事件的能力,必须要实现这个接口。
记得红皮书里讲那个迭代器部分,说要想具备迭代功能,必须实现迭代协议。事件也类似,
EventTarget是宿主环境提供的一种能力 一种功能 一个对象,拥有了它 就拥有了事件的能力。
这里要注意的是,事件 是宿主环境 也就是浏览器提供的,并不是js语言本身所有,这点很重要。
那么,如何获得这种能力呢?
- 继承源头
创建一个类 , extends EventTarget ,直接获得原生的正宗事件能力。
- 纯手工写
- 引入其他事件库
- 框架内置
以上所说,是如何获得事件的能力, 而在平常的开发中,绝大部分,都是通过原型链,直接继承了EventTarget ,并不需要特地去获得。
所以,在很多文章中,并没有提到EventTarget,因为单纯从js的角度来说,它处理的元素 节点 对象 等等 都已经通过原型链拥有了 或者通过一些框架内的自定义实现了或者封装了事件的能力。
- 事件的来源
事件的来源,分两个层面,一个层面 是规范中定义的来源,灵一个层面 是浏览器具体实现的队列。
- 规范定义
- dom源
- ui用户接口源
- 网络源
- 导航和历史源
- 渲染源
这是几个主要的事件来源。
- 浏览器的具体实现
浏览器将不同的来源的事件,映射为自己的多个任务队列,并不是完全按照规范中定义的来源来划分宏任务队列的。至于优先级,浏览器有自己的优化和调度策略 比如用户交互高优先 防鸡鹅调度打捞低优先等等。
这些队列,一般来说是依据优先级的大小来划分。
- 输入事件队列 通常是最高优先级
处理用户的交互,保证用户打字 滚动 点击 没有延迟
- 计时器事件队列 普通优先级
settimeout 等, 有限的优先级,定时器中的回调函数都放在这里等待执行。(settimeout实际是一个浏览器提供的api函数,它是一个同步执行函数,但是做的是异步调度的工作)
- 普通事件队列 一般默认优先级
最常用的队列 处理逻辑的主战场 网络 文件 数据 等等
- 空闲队列 最低优先级
requestIdleCallback
事件循环完全空了 没事做的时候 来这里瞄一眼。
- 事件和观察者模式
观察者模式是一种软件设计模式。它定义了一种一对多的依赖关系。
- “一” : 指的是被观察者。当它的状态发生改变时,它会对外发送通知。
- “多” : 指的是观察者。它们一直盯着“被观察者”,一旦收到通知,就会自动执行相应的操作。
而事件机制,是对观察者模式的一个实现。
我们写代码时
- DOM 节点(如 button) 就是 被观察者。
- 我们写的回调函数(function() { ... }) 就是 **观察者 **。
- addEventListener 就是整个观察者模式的核心api,它安排了一个或多个观察者去盯着被观察者。
- 被观察者状态发生改变,触发通知。
- 事件和DOM事件
可能还是有不少朋友对事件这个概念有疑惑。
事件 是归属于宿主环境的,请记住js语言中 并没有事件的概念。
事件是一个信号,是系统内发生的任何值得注意的事情。比如:键盘按下了、图片加载完了、网络断了、数据到了。。。。。。
DOM 事件只是这个庞大信号系统中的一部分,专门负责网页内容(文档)层面的交互。
之所以把DOM事件单独拿出来说,是因为它是我们在编写代码时,接触最多的一类事件。- DOM 事件 是指发生在 HTML 文档元素(节点) 上的特定的交互瞬间。
- 核心特征: 它们必须依附于某个 DOM 节点(如 , <button>, document)。
- 典型场景: 用户和网页 UI 的交互。
- 常见例子:
- click (鼠标点击)
- keydown (键盘按下)
- submit (表单提交)
- touchstart (手指触摸)
复制代码 那么作为对比,除了DOM事件以外,还有什么非DOM事件呢?- A. BOM (Browser Object Model) 事件 / Window 事件
- 这些事件发生在浏览器窗口层级,而不是具体的 HTML 标签上。
- resize: 浏览器窗口大小被改变。
- scroll: 页面滚动(虽然常绑定在 document,但本质是视图窗口的行为)。
- hashchange: URL 的锚点(#后面部分)发生变化(单页应用路由的基础)。
- storage: localStorage 或 sessionStorage 被修改时触发(用于跨标签页通信)。
- online/offline: 网络连接状态断开或恢复。
- B. 网络请求事件 (Network Events)
- 当 JS 发起异步请求时,请求的状态变化也是事件。
- XMLHttpRequest (AJAX):
- readystatechange: 请求状态改变。
- progress: 下载进度。
- load/error/timeout: 请求成功、失败或超时。
- WebSocket:
- open, message, close, error。
- C. 媒体事件 (Media Events)
- 专门针对 <video> 和 对象的播放状态。
- play / pause: 播放/暂停。
- ended: 播放结束。
- volumechange: 音量改变。
- waiting: 缓冲中。
- D. 跨线程/跨窗口通信事件
- Web Worker: message 事件(主线程和 Worker 线程互相发消息)。
- iframe: message 事件(父页面和子页面通信,即 postMessage)。
- E. 开发者自定义事件 (Custom Events)
- 这是最高级的用法。不由浏览器触发,而是由代码手动触发。
- 使用 new CustomEvent() 创建,使用 dispatchEvent() 发送。
- 用途: 用于组件间通信。可以手动派发一个事件,而不是依赖点击。
复制代码 那么,DOM事件和非DOM事件,有什么区别吗?
DOM 事件:
因为 DOM 结构本身是一棵树(Tree)。 当你点击一个按钮时,你不仅仅是点击了这个按钮,你同时点击了包裹它的 div,点击了 body,点击了 html,甚至点击了整个浏览器窗口。
- 特征: 事件会在 DOM 树上“旅行”。
- 路径: 捕获阶段(从外向内) -> 目标阶段(到达节点) -> 冒泡阶段(从内向外)。
- 结果: 你可以在父节点(比如 div)上监听到子节点(button)的事件。这就是事件委托的基础。
非 DOM 事件:
比如 XMLHttpRequest(网络请求)或 Worker(线程通信)。它们的对象没有“父节点”的概念,它们是内存中独立的 JS 对象。
- 特征: 只有目标阶段。
- 路径: 事件直接发送给该对象,触发完就结束了。它不会传给它的“上级”(因为它没有上级)。
- 结果: 你不可能在 window 上通过冒泡监听到某个具体 ajax 请求的 load 事件(除非你自己手动去转发)。
除了上面所说的传播机制不同,还有一个极其重要的区别:与浏览器原生行为的绑定。
- DOM 事件: 通常带有浏览器的默认行为。
- 标签的 click 会导致跳转。
- 的 submit 会导致刷新页面。
- 键盘的 keydown 会导致输入文字。
- 因此: DOM 事件提供了 e.preventDefault() 来阻止这些行为。
- 非 DOM 事件: 通常纯粹是信息通知。
- XHR 的 load 只是告诉你加载完了。
- 因此: 非DOM事件通常没有(但有例外)所谓的“默认行为”可供阻止。你调用 e.preventDefault() 没有任何意义。
- 事件和事件对象event
通常来说,一个事件,之所以能成为事件, 要具有三个特质:
- 遵循观察者模式
- 携带事件的现场数据
- 可观测的发生或状态的改变
那么 携带事件的现场数据 ,这个就是要讲的event了。
很多文章说,事件发生 比如鼠标被点击 马上就有事件对象被创建, 这个说法其实并不准确。
严谨的描述 event 的创建时机:在事件被包装成任务 放入红任务队列排队 然后被取出开始执行,执行的第一步 是进行命中测试,确定事件发生的目标, 第二步,才是创建事件对象 。 第三步 是路径计算,确定传播路径。
关于具体的流程,下面会详细讲。这部分作为综述,只是讲事件对象本身。
在js层面的事件对象被创建之前,所有的相关信息,只是作为一个内存中的 c++ 结构体存在。
那么 这里可以再给事件对象一个较为明确的定义:
事件对象是浏览器将底层存有事件信息的 C++ 结构包装成 JS 对象,并在路径计算前完成创建,目的是为了让路径计算算法能读取其配置,并再气候的传播过程中充当一个携带现场数据及动态上下文的载体。
我们知道,以前的很长一段时间,前端的情况是 先有规范 再有实现 或者 先有实现 才有规范 或者虽有规范 但是实现不完全符合规范 ,总之是比较混乱,但是现在的情况已经好了很多,我们已经可以逐渐的信赖规范了。学习的时候 尽管实现上有些许的差别,但是可以用规范去加强理解。
规范含义:在 ECMAScript 相关的规范中,[[ ]] 形式的名字表示一种抽象的内部插槽,它们定义了对象在语义上的内部状态或行为。它们是规范用来描述对象如何工作的术语,不是 JS 层能直接访问的普通属性。
实现层面:js引擎和浏览器会用各种方式来实现这些规范中定义的抽象的内部插槽。
JS 提供的可访问接口:很多内部插槽会通过公开的属性或方法提供出来(例如 event.type、event.target、event.bubbles 等,不止事件对象,js的其他对象也是如此。),这些公开接口并不是“直接读写了内部插槽”,而是这些内部状态的一种通过api暴露出来的方式。
因为事件对象可以说是事件中最重要的部分,所以,很有必要重点来学习,下面 我们用比较大的篇幅来详细学习事件对象。
事件对象,从js的角度来讲,它确实是一个真正意义上的对象,我们平常从红皮书 或者权威指南上看到的js对象的定义,略有简化,请记住这个终极理解:
js对象的本质 = 非原始值 + 属性记录集合 + 原型链继承 + 由内部槽/内部方法决定行为
从这个角度来说, 事件对象完全符合js对象的本质定义。
读过js红皮书的朋友也许记得,在不少章节中 都有 [[...]] 这样的内部属性的写法,也就是上面所说的内部插槽。
我们首先介绍js事件对象的内部插槽:
核心状态插槽
定义在 Event 接口中,所有事件对象共用。
内部槽位类型描述[[type]]String事件类型(如 "click", "load")。初始化时设定。[[target]]EventTarget?初始派发目标。在 dispatchEvent 调用时被设定。[[relatedTarget]]EventTarget?与事件相关的次要目标(主要用于 MouseEvent 和 FocusEvent)。注意:它也参与重定位。[[currentTarget]]EventTarget?当前正在执行监听器的对象。在传播过程中实时更新,派发结束后重置为 null。[[eventPhase]]Integer当前阶段:0 (NONE), 1 (CAPTURING), 2 (AT_TARGET), 3 (BUBBLING)。[[timeStamp]]DOMHighResTimeStamp事件创建时间(相对于 Time Origin 的高精度时间戳)。[[isTrusted]]Booleantrue 表示由 UA(浏览器)生成;false 表示由脚本创建。[[path]]List传播路径。由一系列结构体组成,每个结构体包含 item (invocation target) 等信息。[[touch target list]]List(仅用于触摸逻辑)用于处理“隐式捕获”,即手指移出元素后仍将事件发送给初始目标。[[path]] 是传播路径,关于它的结构和填充,我们后面会详细的学习。
标志位插槽
通常在实现中会被压缩为一个 Bit Field 以节省内存。
内部槽位 (Flag)描述[[stop propagation flag]]设置后停止向后续节点传播(stopPropagation)。[[stop immediate propagation flag]]设置后停止传播且停止当前节点剩余监听器的执行。[[canceled flag]]设置后表示默认行为被阻止(preventDefault)。[[in passive listener flag]]标识当前是否处于 passive 监听器中(此时忽略 preventDefault)。[[composed flag]]标识事件是否可以穿越 Shadow DOM 边界传播。[[initialized flag]]标识事件对象是否已完成初始化(防止重复调用 initEvent)。[[dispatch flag]]标识事件是否正在派发中(防止重入/多次 dispatch)。[[bubbles]]标识事件是否支持冒泡。[[cancelable]]标识事件的默认行为是否可取消。子类专用槽位
根据事件类型(C++ 类)的不同,按需存在的槽位。以下列举最核心的几类。
a. CustomEvent 接口
内部槽位描述[[detail]]存储开发者传入的自定义数据(payload)。b. UIEvent 接口 (鼠标、键盘事件的基类)
内部槽位描述[[view]]通常指向 WindowProxy(即 window 对象)。[[detail]]对于 UI 事件通常是数字(如点击次数),不同于 CustomEvent 的 detail。c. MouseEvent 接口
内部槽位描述[[screenX]], [[screenY]]屏幕绝对坐标。[[clientX]], [[clientY]]视口(viewport)相对坐标。[[ctrlKey]], [[shiftKey]], [[altKey]], [[metaKey]]修饰键状态(按下为 true)。[[button]]触发事件的按键(0:左,1:中,2:右)。[[buttons]]当前按下的按键(位掩码,例如 1=Left、2=Right、4=Middle、8=Back、16=Forward)。d. KeyboardEvent 接口
[table][tr]内部槽位描述[/tr][tr][td][[key]][/td][td]键值字符串(如 "Enter")。[/td][/tr][tr][td][这就是为什么这种方式,可以直接在html里使用event , id,document的console的原因。
这种方式是强耦合的典型,HTML 和 JS 逻辑死死纠缠在一起。而且,因为 with 语法的存在,变量查找路径变得极其复杂,极易引发性能问题和意想不到的 Bug。而且,这种内联脚本,经常会因为安全问题,被禁止运行。
- DOM0 级事件处理 (DOM0 Event Handlers)
随着 JS 的地位提升,还想再提升 再提升 于是就希望能把逻辑从 HTML 中剥离出来。于是出现了 DOM0 级绑定。- btn.onclick = function() {
- console.log('你好了吧');
- }
复制代码 这种方式的本质,是对 DOM 对象上的一个属性进行赋值。
(重要)这两种方式的总结
现在我们来撸一下思路,事件被包装成任务,放入宏任务队列, 然后被取出执行,精确命中,创建事件对象,构建传播路径, 此时,就进入调度阶段。 此时 我们把目光放在传播路上的某一个节点/元素/eventtarget 上面,它的内部 有一个自它出生就有的一个事件监听列表,该列表初始为空, 而此节点,作为一个对象,一个元素,它本身是有自己的属性的,比如 src属性 onclick属性,等等。。。以onclick为例,它是节点对象元素标签的一个属性,它在事件监听列表中,拥有一个单独的席位,初始为空,并不实际占有位置。 按照注册顺序来排列监听列表。 比如首先 add了几个回调, 然后又以btn.onclick=fn的方式注册了onclick, 那么 onclick是排在最后的。 又比如,首先以html的写法的方式内联注册了onclick,那么在html解析时,该属性就被注册了,它就排列在事件监听列表的首位。
还有一个重要的地方,就是 onclick是作为节点的一个固有属性存在的,它的值只能有一个,多次赋值会被覆盖。
而后面将要讲的add的方式添加的,是附加的方式,可以添加多个。
最后再次总结一下:
对于 HTML 属性绑定和 DOM0 (btn.onclick) 绑定,它们在浏览器内部,其实共享同一个内部槽位: 它们会在事件监听列表中寻找(或者创建)一个带有特殊标记的表项。
- 唯一性: 这个表项,对于同一种事件类型(比如点击),只能有一个。
- 独占性: 无论你赋值多少次 btn.onclick = fn,浏览器做的不是“添加”,而是“原地换人”。它找到那个表项,把里面的 callback 字段擦掉,填入新的函数。这就是为什么 onclick 永远只能绑定一个处理函数,因为它霸占了这个唯一的列表项。
- 生命周期: 如果你把 btn.onclick 设为 null,浏览器就会把这个 表项从列表中物理移除。
- DOM2级事件监听addEventListener
随着 Web 应用越来越复杂,组件化开发成为主流,如果一个按钮既要发送统计数据,又要执行业务逻辑,还要触发 UI 动画,用 onclick 就会互相打架。于是,addEventListener 诞生了。
它的逻辑和以前完全不同。dom0是独占和唯一,那么 addEventListener 就是 “追加”。- btn.addEventListener('click', fn1);
- btn.addEventListener('click', fn2);
复制代码 调用这个 API 时,浏览器它只会做一个动作:Append(追加)。 它创建一个新的表项,填好 type、callback,然后直接把它挂在列表的末尾。
它的优势:
- 无限叠加:你可以添加无数个监听器,它们和平共处。当事件触发时,浏览器会按照列表中的顺序(也就是注册的顺序),依次执行它们。
- 精细化控制:这是 DOM0 做不到的。
- 你可以控制是在捕获阶段触发还是冒泡阶段触发(通过 capture 选项)。
- 你可以控制它是否只执行一次(once: true)。
- 你可以承诺不阻止默认行为以提升滚动性能(passive: true)。
- 你可以随时用信号终止它(signal)。
那么,是不是真的可以无限叠加任何监听呢?并不是。
为了防止你因为代码逻辑混乱或者一时糊涂手抖而重复注册同一个函数,浏览器在追加之前,会有一个严格的查重机制
这道机制只认三个字段:
- type(事件类型)
- callback(回调函数引用)
- capture(捕获状态)
请注意,只有这三个! passive、once、signal 这些后来加入的参数,不参与去重判断。
就是说 如果你先注册了一个 { passive: true } 的点击事件,然后又注册了一个一模一样的函数,但是参数变成了 { passive: false }。 浏览器会对照字段:
- Type 一样吗?一样 (click)。
- Callback 一样吗?一样 (同一个函数引用)。
- Capture 一样吗?一样 (默认都是 false)。
结果判定为重复人员! 浏览器会直接忽略第二次的注册请求。列表里依然只有第一次的那条记录。尤其要注意capture这个字段,前两个字段一样,第三个,真和假 可以同时存在于监听列表中。
还有一点
addEventListener的第二个参数,也可以是一个对象,这个对象里面,必须实现一个handleEvent方法:- const myObj = {
- message: 'Hello World',
- handleEvent: function(event) {
- // 这里的 this,自动指向 myObj 对象本身
- console.log(this.message);
- console.log(event.type);
- }
- };
- // 传入的是对象,而不是函数
- btn.addEventListener('click', myObj);
复制代码 这种方式一是有利封装 二是不用绑定this,三是移除方便 。但这种传对象的方式我们平时使用不多。
2. removeEventListener
有注册就有注销,addeventlistener是往事件监听列表里添加观察者,removeeventlistener 就是用来把观察者从列表中请出去的。 它的工作很简单,就是使用上面提到的那三个字段去列表里找人:
- type
- callback
- capture
符合条件,就请出去了。
这也是红宝书上说的,必须符合三个条件的原因,因为添加的时候,用这三个条件判断是否是重复添加, 所以用这三个字段,可以唯一表示事件监听列表里的某一项,那么在移除时,依旧是使用这三个字段来寻找。
那么问题来了,记得不要使用箭头函数当回调。因为,回调函数,如果是匿名的,你在注册时,它是一个对象,有一个内存地址, 你在移除时,写的回调,虽然和注册时是一样的内容,但是它是另一个不同的对象,有另一个不同的内存地址, 移除时并不是比对内容,而是比对的内存地址。地址不同,当然移除不掉的。- // 注册
- btn.addEventListener('click', () => { console.log('猜猜我是谁') });
- // 试图移除
- btn.removeEventListener('click', () => { console.log('猜猜我是谁') });
复制代码 还有一点 要特别注意capture 是必须要匹配的!
在浏览器的眼中,捕获阶段的监听器 和 冒泡阶段的监听器,是完全不同的,- // 注册了一个捕获阶段的监听器
- btn.addEventListener('click', handler, { capture: true });
- // 试图移除一个冒泡阶段的监听器
- btn.removeEventListener('click', handler, { capture: false }); // 失败嘞
复制代码 虽然函数一样,类型一样,但一个是捕获阶段,一个是非捕获阶段,浏览器认为它们不是同一个列表项。 要想移除上面那个,就必须显式地写上 { capture: true }。
关于事件监听列表种的第7个字段removed
还记得我们在前面介绍监听列表的7个字段时,提到的那个 内部专用字段 removed 吗? 我们在这里略为介绍一下。
想象一下,假如有一个按钮,练功走火入魔了,居然注册了 10 个点击事件监听器。 当点击发生时,浏览器开始在一个 for 循环 中遍历这 10 个监听器,依次执行。 假设执行到第 3 个监听器时,它的代码里调用了 removeEventListener,把第 4 个监听器给删了。如果浏览器直接把第 4 个项从数组里 物理删除,数组长度这就变短了,后面的元素下标全部前移。 原来的第 5 个变成了第 4 个。 而循环的索引 i 此时加到了 4。 后果就是 ,原来的第 4 个被删了,原来的第 5 个被跳过了。
为了避免这种遍历中修改所带来的索引bug,浏览器采用了 “软删除” 策略。
当调用 removeEventListener 时:
- 浏览器找到了对应的表项。
- 它不会立即把它从内存里删除。
- 它只是悄悄地把该表项的 removed 标志位设为 true。
在事件派发的循环中: 当轮到这个表项时,浏览器会先看一眼:“哎呀 removed 是 true?” 然后 直接跳过不执行,继续下一个。
等到这一轮事件循环彻底结束,或者在未来的某个空闲时刻,浏览器才会真正地回收这些“被标记的僵尸”,释放内存。 这就是为什么说 removeEventListener 是一个逻辑上的删除,而不是物理上的立即消灭。这就是这个removed字段的用途。
那么 问题又来了, 哎呀,这么麻烦丫,删点东西 又要这 又要那的,有没有更先进的办法呢? 这就是事件监听列表中 第6个字段signal 出现的意义了。
在前面,我们特别的讲了,不要传匿名函数进去当回调,因为想移除的时候,会匹配不到。那么现在有了signal的加持,匿名函数也能支楞几下了。- const controller = new AbortController();
- // 注册时,把销毁信号传进去
- btn.addEventListener('click', () => { console.log('你们逮不到我'); }, { signal: controller.signal });
- // 想移除时,不需要知道函数是谁,直接按下引爆---砰
- controller.abort();
复制代码 AbortController 是一个构造函数, 使用new AbortController() 实例化出一个控制器对象。
这个对象很简单,包含一个signal属性,一个abort方法
这个对象是宿主环境提供的
AbortController 的出现,就是为了提供一种通用的取消机制。
使用 removeEventListener 时,必须使用回调函数的引用。但是用 AbortController,不需要管回调函数是谁,只需要控制那个信号。
而且,可以一对多的控制,可以把同一个 signal 传给 10 个不同的 addEventListener,甚至传给几个 fetch 请求。当调用一次 controller.abort() 时,这 10 个事件监听器和那几个网络请求,会同时停止。一键清理,厉害大了。
3. dispatchevent
dispatchevent的执行,和内部的派发过程是一样的,可以认为,它是内部的派发算法给js层面提供的一个接口。具体的执行,在后面会有超大的篇幅来讲。
在这部分 我们主要讲一下自定义event
在前面的第一部分讲解event的时候,我们说 自己创建event对象,需要使用对应的构造函数,因为内部槽位有通用的 也有专用的。
- new Event()
const evt = new Event('boom');
这种,就纯粹是个消息通知,听个响而已,派发它,只能用于通知,看到通知,就回调。
- new CustomEvent()
DOM 规范专门提供了:CustomEvent。 它是我们日常开发中最常用的方式。- const payload = {
- username: '阿祖',
- action: '收手吧 外面全是成龙'
- };
- // 第二个参数是配置对象
- const evt = new CustomEvent('police-arrive', {
- detail: payload
- });
- document.addEventListener('police-arrive', (e) => {
- console.log(e.detail.username); // 阿祖
- });
复制代码 detail里面可以放任意类型的内容,使用非常方便。
- 使用 EventInit 可配置对象
对于上面这两种 event和customevent,还可以使用配置对象对他们进行配置。
实际上,这种配置,是对于event内部插槽的修改,对于这两种属于基类的,只能配置
三个功能: 是否可冒泡bubbles 是否可取消cancelable 是否可跨影dom边界composed,他们初始默认都为假。
对于一般使用,以customevent加detail加三个配置项 居多。
- 继承 Event 类
使用 class myEvent extends Event {}
这种深度定制,可定制事件类型 可定制高内聚的逻辑。
但是写起来比较麻烦。
可能有新手朋友会有疑问 我new event 然后自己添加,和我使用extends event继承,有什么区别吗? 不都是要自己添加吗? 对于特别简单的,当然可以new以后添加,但是稍微复杂点的,尽量使用继承,new加上添加,会有不可预知的安全问题,强类型,封装性 ,安全性,可固化配置。。这些优势,足够驱使选择继承的方式了吧。
- 那么 我想精确的造一个点击事件怎么办
这就需要拥有特定专用内部槽位的子类出场了,点击事件是MouseEvent- const perfectClick = new MouseEvent('click', {
- //下面的配置项目,就相当于修改event对象中的内部槽位
- //每种子类,拥有通用内部插槽, 也必须有自己的专用内部槽
- // 1. 基础配置 通用槽(继承自 EventInit)
- bubbles: true, // 必须为 true,否则父元素收不到冒泡
- cancelable: true, // 必须为 true,否则无法 preventDefault
- composed: true, // 穿透 Shadow DOM
-
- // 2. 视觉上下文(继承自 UIEventInit)
- view: window, // 绑定当前窗口
-
- // 3. 物理信息 这是鼠标事件的专用内部槽(MouseEventInit 特有)
- clientX: 100, // 鼠标相对于视口的水平坐标
- clientY: 200, // 鼠标相对于视口的垂直坐标
- screenX: 100, // 相对于屏幕的坐标
- screenY: 200,
-
- // 4. 按键详情 依旧是鼠标事件专用内部槽
- button: 0, // 0: 左键, 1: 中键, 2: 右键
- buttons: 1, // 当前按下的键的位掩码 (1 代表左键被按下)
-
- // 5. 修饰键 配合键盘使用
- ctrlKey: false,
- altKey: false,
- shiftKey: true, // 假装用户同时按住了 Shift
- metaKey: false,
-
- // 6. 关联目标 这个内部槽位的详细说明 请参见本文的第一部分
- relatedTarget: null // mouseover/out 时有用
- });
- // 开车喽~~~
- btn.dispatchEvent(perfectClick);
复制代码 这部分内容,是event的创建, 因为dispatchevent派发 就必须讲到这部分。所以就放在这里了。
关于dispatchevent,下面专门详细的介绍。
事件的派发和处理
- 梳理线索 整理思路
现在,我们来快速梳理一下我们已经学过并掌握的知识脉络
- 事件对象
事件的三个特质 ,1是遵循观察者模式,这样才能发布-订阅-移除-处理 ,2是携带事件的现场数据,这就是event对象,事件的传播以它为主, 3是可观测的发生活改变,这个就不用说了。
事件对象event的创建是在什么时候?回忆一下第二部分的流程,以点击事件为例,物理信号-操作系统路由-进程间通信给到渲染器-合成线程接收进行预先独立合成-合成器进行一次大致的命中测试-事件路由决策-被封装成任务进入宏任务队列-取出开始执行-深度命中测试找出目标-创建js层event-构建事件传播路径
(实现上以v8/blink为例)
通常 在创建js层事件对象 构建事件传播路径 甚至包括调度部分 明显的界限不好区分,因为有浏览器的实现差别和优化策略的不同,但是并不影响我们理解。
event是贯穿全程的唯一信物。它是一个底层 C++ 对象,内部包含了大量的内部插槽,JS 层的 event 对象只是它的一个浅层包装壳/代理。
身份信息
- [[type]]:事件类型(如 "click", "mousedown")。
- [[isTrusted]]:true(浏览器生成)或 false(用户脚本生成)。
- [[timeStamp]]:高精度时间戳(事件创建那一刻的时间)。
- [[target]]:原始目标。即精确的命中测试(Hit Test)找到的最精确的 DOM 节点。注意:这个值永远不变,但在传播过程中对外暴露的 event.target 属性会骗人---因为有可能存在影dom的情况。
- [[relatedTarget]]:(仅限 mouseover/out 等具有关联对应节点的情况)相关的那个节点(原始值)。
静态配置
- [[bubbles]]:布尔值。决定是否允许进入冒泡。
- [[cancelable]]:布尔值。决定 preventDefault() 是否生效。
- [[composed]]:布尔值。决定事件是否能穿透 Shadow DOM 边界传播。
动态控制标志位 初始状态均为关闭,随 JS 代码执行动态变化。
- [[stop propagation flag]]:封路标记。若为 true,当前节点执行完后,停止传播。
- [[stop immediate propagation flag]]:熄火标记。若为 true,当前节点剩余监听器不执行,且停止传播。
- [[canceled flag]]:撤销标记。若为 true(即调用了 preventDefault),后续将阻止默认行为或触发 UI 回滚。
- [[in passive listener flag]]:静默标记。标识当前是否处于 passive 监听器中(此时忽略 preventDefault)。
- [[dispatch flag]]:运行标记。标识该事件是否正在派发中(防止同一个 Event 对象被重复 dispatch)。
极其重要的内部槽位
传播路径列表存储在 Event 对象的 [[Path]] 插槽里。 它是静态的。一旦派发开始前计算完成,它就锁死了。即使你在某个回调里把父元素删了,事件传播依旧会沿着已经计算好并岁锁死的路径传播。
列表中的每一项 不是简单的 DOM 节点,而是一个结构体,包含以下7个字段:
- item (当前哨位):
具体的 DOM 对象(Window, Document, Element, ShadowRoot 等)。 这是 currentTarget 在当前的真实指向。
- target (Shadow 修正目标):
关键数据。这是算法预先计算好的、在当前的哨位应该对外暴露的 event.target。
逻辑:如果当前哨位是 Shadow Host,这里就是 Host;如果是在 Shadow DOM 内部,这里就是真实的内部节点。(为了封装性而撒的谎)。
- relatedTarget (Shadow 修正关联目标):
同上。预先计算好的、对外显示的 event.relatedTarget。
- touch target list: (仅限 Touch 事件)
经过 Shadow DOM 边界修正后的触点列表。
- root-of-closed-tree:
布尔值。标记该路径项是否是一个 closed 模式的 Shadow Root。用于隐私保护。
- slot-in-closed-tree:
布尔值。用于处理复杂的 Slot 分发场景。
- invocation-target-in-shadow-tree:
布尔值。标记当前哨位是否位于 Shadow DOM 树内部。
- 节点上的监听列表 (The Listener Lists)
虽然它们是即时读取的,但它们客观存在于每一个 DOM 节点上。
- 持有者:每一个实现了EventTarget 接口的dom对象。
- 数据结构:事件监听器列表。
- 每个列表项包含字段:
- type (事件类型)
- callback (函数或对象)
- capture (捕获标记)
- passive (性能标记)
- once (一次性标记)
- signal (引爆销毁信号)
- removed (软删除标记 - 初始为 false)
- 派发与回调调用
经过上面的快速梳理 ,我们已经知道,有三样最重要的东西 事件对象 传播路径表 传播路上的每个节点的监听列表。
现在我们开始发车吧,开启一段有趣的旅程。
嘀嘀嘀 喇叭响了,浏览器引擎启动了主循环,这辆车,要跑两个半程。
1 capture 去程, 从window向下,达到事件目标核心target
2 bubble 回程, 从目标核心 target浮起,一路冒泡到window。
现在我们把车子放慢 再放慢, 停在某一站
第一步 伪装与身份切换 retargeting
车门还没开,浏览器引擎先搞搞伪装,它必须修改event中的数据,以便符合自己在此站点/节点的身份,也为了欺骗此地哨位。
- 锁定现场 (currentTarget):
浏览器引擎将 event.currentTarget 指针,锁向当前这一站的 DOM 节点。确定当事人。
- 撒一个完美的谎 (target 重定向):
这里涉及到 Shadow DOM 的机密。引擎迅速读取event对象中的path内部槽位中的当前结构中的 shadow-adjusted target内容,覆盖了 event.target。
从之前的学习中,我们知道这个值是根据影dom修正过的值,此时直接覆盖。
shadow-adjusted target的值 针对当前的节点 始终都是正确的,这个覆盖的步骤,是必做的一步。也是每经过一个哨位,都必做的一步。
第二步 精确的时间段控制
- 捕获阶段 1 车还在去程的路上,离终点还远呢
- 冒泡阶段 3 车已经返程,快完事了
- 目标阶段 2 这是最忙碌的换向站点。
实际上 车会两次经过这里, 捕获阶段到达,引擎让捕获组的来,即找出 capture: true
冒泡阶段到达,引擎让冒泡组的来,即找出 capture: false
尤其是在目标阶段 ,目标元素上既会执行 capture:true 的监听器,也会执行 capture:false 的监听器;根据最新的规范:通常 capture 监听器先执行,然后再执行非捕获监听器(除非 stopImmediatePropagation() 等标志打断)。
第三步 提取与快照
此时,引擎敲开当前哨位的门,索要该节点的事件监听列表。
- 哨位给出原始事件监听列表
- 引擎拍个照片,形成快照,依据快照进行后续操作。
那么 假如某个回调使用add添加了几个监听,新加的几个 会正常附加在原始事件监听列表尾部,
但是因为引擎是根据 快照 来执行,所以本轮派发没有新添加的份。
假如 某个回调 把它后面的回调移除了,原始事件监听列表中的回调,就真的被移除了,同时移除操作还会将该被移除的回调的removed字段设置为true。看到这里 你可能有疑问,不是被移除了 怎么还能设置它的字段? 实际上, 不管是原始列表 还是快照, 都是使用的指针, 指向的真正的本体。原始列表中 该字段被标记为软删除,操作的是本体上的该字段,然后移除原始列表中的指针, 本体仍然健在,因为还有快照中的引用在指向它,不能销毁。
另外 快照是按照事件类型匹配后的完整监听器列表,并不是完整的原始事件监听列表。
规范中规定,先取得完整的事件监听列表的快照,然后进行包括type在内的各项比对,
但是在浏览器的实际实现中,已经预先使用了按照事件类型分组 或者其他便捷的组织方式,
所以得到的快照,直接便是按照事件类型匹配好了的列表。
其它条件capture/bubble、once、removed、abort、passive 都在执行阶段对快照 中的每一项逐条检查。
第四步 内部循环
现在 浏览器引擎拿着快照,开始点名核对
- 指纹核对
type核对(一般在取得快照时,得到的是已经匹配过当前事件类型的列表了)
phase 阶段核对,浏览器引擎 根据自己的一套规则,确定当前的所处阶段,以此来过滤回调。
- 状态检查
removed? 引擎发现这个名字上有removed标记,直接跳过。
aborted? 引擎看了眼abortsignal,标志为真?直接跳过。
关于这个信号,再详细介绍一下,依旧是 监听项本体在堆内存中,Signal 对象 (Controller)也在堆内存里,监听项本体保存对signal的引用。当有js代码调用 controller.abort()时,JS 引擎找到内存里的 Signal 对象,把它的 aborted 字段从 false 改为 true。另外 在abort()被调用的时候,原始事件监听列表中的该项,也即时被删除 如果还在派发中,则快照上依然保留该项,以防索引bug,但是被标记为软删除 。 实际上,在signal对象内部,也被浏览器注册了一个回调函数,用于主动清理工作,这个内容太超纲了 略过。
当引擎按快照里的顺序,开始检查核对该项时,检查到aborted字段,由快照指针 找到监听项本体,顺着其持有的signal对象的指针,找到signal对象,发现状态为aborted: true ,则直接跳过。
- once 机制
浏览器引擎看到once为真的标记,立即把该项从原始事件监听列表中移除。
现在只在快照里了,只能执行这一次。
- passive机制
看到 passive: true,浏览器引擎给 Event 对象打了个钢印:“忽略反对意见”。
此时你在回调里无论怎么 preventDefault(),都是没用的,浏览器甚至还会在控制台贴一张警告条:“别喊了,你就算喊 破喉咙,也没用的。”
- 执行回调与异常抵抗
终于,js引擎出场,调用回调函数,开始执行。
突然,异常出现,某个回调函数崩了,抛出error,
浏览器进行记录,显示在控制台上,
然后开始快照里的下一条监听项的核查比对。
- 检查与制动
每一个回调执行完,浏览器引擎都会检查event事件对象中的各种标志位,js代码刚才有没有搞小动作?
检查 [[stop immediate propagation flag]]
如果为真,直接散伙,循环中断,转去判断是否执行默认。
检查 [[stop propagation flag]]
如果为真,干完这票就收工。快照上的监听项依次干完, 然后转去判断是否执行默认。
- 默认行为的处理
无论是顺利跑完了全程,还是半路被停止或者是干脆原地散伙,JS 的逻辑阶段都宣告结束。
此时,浏览器引擎会做最后的清算(注意:停止传播不等于取消默认行为):
- 返回值生成:dispatchEvent 会返回一个布尔值。
- 当且仅当事件可取消(cancelable: true) 且至少有一个监听器调用了 preventDefault() 时 返回 false。
- 否则 返回 true。
- 默认行为:
- 引擎只看 [[canceled flag]]。
- 哪怕传播在第一站就停止了,只要没人反对(调用 preventDefault),浏览器依然会执行默认行为(如跳转链接、提交表单)。
此时,同步的 dispatchEvent 调用栈清空并返回。
微任务开始了。
一些重要知识点详解
- 在某个节点,浏览器是如何知道当前所处的阶段?
当事件传播来到某个 哨位/节点/标签/实现了eventtarget接口的对象/dom元素 , 当前哨位里,是有原始的事件监听器列表,并没有当前事件动态走向的所处阶段,那么浏览器是怎么得到这个阶段呢?
很多朋友会说:引擎当然知道 它就是boss 啥都知道,咱只要知道它知道就行了。
话是不错,可但是,我们还是有必要了解一下的。
在事件传播时随身携带的event对象中,内部插槽[[path]]存着计算好的路径,每条路径,都是一个列表,里面有7个字段。
引擎使用这种存储方式 Path[0] = target Path[last] = window 来存储需要走的半程
- **捕获循环 **:
- 引擎设置 i 从 Path.length - 1 (Window) 开始,递减到 1 (Target 的父亲)。
- 只要循环在这个范围内,引擎就强行把 eventPhase 设为 CAPTURING-PHASE (1)。
- 只要没走到索引 0,且我在倒着走,那我就是在捕获阶段
- 目标循环 :
- 引擎设置 i = 0。
- 只要 i 是 0,引擎就强行把 eventPhase 设为 AT_TARGET (2)。
- 我踩在终点上了。
- 冒泡循环:
- 引擎设置 i 从 1 (Target 的父亲) 开始,递增到 Path.length - 1 (Window)。
- 只要循环在这个范围内,引擎就强行把 eventPhase 设为 BUBBLING_PHASE (3)。
- 我已经离开索引 0 了,且我在正着走,那我就是在冒泡。
so 并不是 phase 决定了怎么走,而是 “怎么走”决定了 phase。
引擎使用Path,通过控制遍历的起点、终点和方向,从而精准地定义了当前的“时空状态”,这就是它为什么在某一节点,能用自己知道的 所处阶段,去和节点内部的原始事件监听列表的快照进行对比核查的原因。
即使在Shadow DOM存在的情况下,path依然正确有效。
比如需要确定target时
event.target = Path.shadow_adjusted_target
- 如果 i 在影内,修正目标字段里存的就是内部节点。
- 如果 i 在影外,修正目标字段里存的就是 Host。
总结就是
浏览器引擎确定状态的方式,不是“动态感知”,而是“读取预设”。
- 所处阶段:由循环索引决定。
- 当前哨位所能看到的目标:由 Path 里的预存字段决定。
- 当前节点:由 Path 里的 item 字段决定。
这就是为什么派发算法如此高效——因为它不需要思考,只需要查表
- 在某哨位 对比核查事件监听器列表时,是全部核查完毕,然后依次执行,还是核查出来一个,就执行一个?
这是严格按照,揪出来一个 就执行一个的方式。
这里有一个极易产生的误解。很多朋友认为浏览器是先把快照里的所有人都撸了一遍,挑出合格的,组成一个新的待执行队列,然后一口气执行完。这是错的。
浏览器的执行逻辑,是严格的 “揪出来一个,处理一个” 的串行模式。
在 for 循环的每一次迭代中,引擎做的事情是完整的闭环:
- 点名:根据索引 i,从快照里指向第 i 个监听器。
- 立即核查:
- “ 你现在被 removed 了吗?” (检查 removed 标记)
- “ 你的 signal 炸了吗?” (检查 aborted 状态)
- “ 你是这个阶段的吗?” (检查 capture/phase)
- **立即执行 **:
- 如果核查通过,立刻、马上、同步调用你的回调函数。
- 之所以说是 串行,是因为 回调函数的执行,是控制权的移交,必须由js引擎来干活了。浏览器引擎先去抽根烟了。
- 注意:此时,第 i+1 个监听器还在队列里等着,所有人都不知道它合不合格。
- 后果:
- 正因为是“执行完一个”才去“找下一个”,所以当前这个回调函数里的操作,能直接决定后续监听器的命运。
- 比如你在第 i 个回调里调用了 stopImmediatePropagation(),引擎在准备进入 i+1 循环之前一检查:“欸,熄火标记亮了?” duang的一声,循环直接 break,第 i+1 个监听器连核查的机会都没有,大家直接散伙。
总结就是: 浏览器不是“批处理”,而是严格的“单步迭代”。 快照保证了“人员名单”不许变(后面新来的进不来),但“生存状态”是每一次迭代时实时核查的。
- 在某个节点上,是 1 对 1 还是 1 对 N?
假如在某个子元素(比如按钮 B)上发生了一个点击事件。事件一路火花带闪电,来到了顶层节点(比如容器 S)。 此时,容器 S 上注册了好几个 click 类型的监听器:有的负责挖坑,有的负责埋雷,有的负责点火,但他们都属于click类型。 那么问题来了:当事件传播到 S 时,是“精准命中”某一个回调执行?还是所有相关的回调都会被执行?
很多朋友会脱口而出,当然是 1 对 1:“我明明是点的按钮 B,浏览器应该很聪明,只执行那个我当初注册的那个处理 B 的回调吧?”
正确的答案是 浏览器引擎执行的是 1对 N 。
还不是很明白的朋友,可以先看一下前面的 派发与回调调用 这一部分内容。
当事件传播的车开到顶层节点 S 时,浏览器引擎拿出 S 的监听器列表(快照),开始选人干活。 它的筛选标准非常简单粗暴:
- Type 对吗? (事件是 click,你监听的也是 click 吗?对。)
- Phase 对吗? (我是冒泡过来的,你是监听冒泡的吗?对。)
- Flag 正常吗? (没被 remove 吧?signal 没炸吧?正常。)
只要这三条符合,不管你回调函数里写了什么,统统揪出来干活
它的策略就是:全部唤醒,依次执行
那么 怎么办呢? 当然是在回调函数里判断了,除了有些业务逻辑需要来着不拒,比如访客点击,每个点击都要记录,不需要加判断,除此以外,第一行代码都是身份判断 因为如果不判断,作为回调函数来讲,不管谁的点击事件来了, 它都得执行一遍。
而作为事件本身来说,它只希望自己期望的回调被执行,其他的回调必须拒绝它。
对于基于事件委托的业务逻辑来说,第一行代码永远都是身份判断,
所以,回调函数里的身份判断,万万少不得。
这里我们再引入一个狠角色 stopImmediatePropagation
一个点击事件,可能会有几个点击事件监听项在等着,当某个监听项调用了stopImmediatePropagation, 好了 都别等了 立刻散伙收工。那么问题又来了,假如有好几个监听项在排队, 我不能精确的保证 该在何处调用这个api?这又是一个问题,所以 要保证你所期待的那个监听项是排在第一 或者是你可以明确的知道 应该在哪里调用
比如 两个点击事件项 A是校验 B是提交 你校验不过,可以直接祭出大杀器stopImmediatePropagation,立即阻止了B的排队执行。
其实这个函数通常在第三方库里使用,因为那些库的初始化 都是先于用户代码,所以库在初始化时会抢先注册监听,通过在适当的时候 使用stopImmediatePropagation来一票否决,实现自己的判断 校验 安全拦截等类似功能。
这是全篇文章的第三部分,这部分内容,我觉得还是比较容易理解的,尤其是前半部分,一般新手朋友,读两三遍,应该能收获不少。事件监听器列表,只要花几个小时,了解一下这个表,对于实际开发中的不少问题,就能心中有数,不知为什么 基本上没有人讲解。
第四篇是事件的循环和异步, 我们下一篇再见。
参考列表:
- developer.mozilla.org
- dom.spec.whatwg.org
- html.spec.whatwg.org
- tc39.es
- developer.chrome.com
- w3.org
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |