五万字沥血事件 深度学习 事件 循环 事件传播 异步 脱离新手区 成为事件达人

一 、事件的综述

​ 首先需要了解几个术语:

  • 宿主环境 :将js引擎作为一个组件包含在内,并且为它提供运行所需的资源的外部系统

​ 就是说,宿主环境提供了所有的资源,比如网络 文件 渲染 各种功能接口等等,没有了宿主环境, js引擎就是光杆司令,它就只能空转,做不了任何事情。

  • 宿主对象:所有不是由 JS 语言本身定义的、而是由环境提供的对象/功能/api,都叫宿主对象。

比如 Fetch document XMLHttpRequest 等等 由宿主环境提供的都叫宿主对象。

而js语言定义的对象,比如 Array, Object, Promise, Math, Map 等等,是js的原生内置对象。

​ 我们常说的 事件循环 任务队列 都是由宿主环境提供并且管理的。

说 js能怎样怎样,实际上是 js语言的能力+宿主环境赋予的能力 。


  1. 事件的核心定义

    JavaScript 中的事件是宿主环境提供的一套标准化的异步消息分发机制,是系统内发生的、可被代码侦测到的"发生"或"信号" 。

    事件是一种能力,那么是不是所有的对象 所有的元素 所有的节点,都具备事件的能力呢?

    并不是所有, 这里就要提到一切的源头 事件目标 EventTarget

    EventTarget是一个接口 是一个对象 ,你要具备事件的能力,必须要实现这个接口。

    记得红皮书里讲那个迭代器部分,说要想具备迭代功能,必须实现迭代协议。事件也类似,

    EventTarget是宿主环境提供的一种能力 一种功能 一个对象,拥有了它 就拥有了事件的能力。

    这里要注意的是,事件 是宿主环境 也就是浏览器提供的,并不是js语言本身所有,这点很重要。

    那么,如何获得这种能力呢?

    • 继承源头

      创建一个类 , extends EventTarget ,直接获得原生的正宗事件能力。

    • 纯手工写

    • 引入其他事件库

    • 框架内置

    以上所说,是如何获得 事件的能力, 而在平常的开发中,绝大部分,都是通过原型链,直接继承了EventTarget ,并不需要特地去获得。

    所以,在很多文章中,并没有提到EventTarget,因为单纯从js的角度来说,它处理的元素 节点 对象 等等 都已经通过原型链拥有了 或者通过一些框架内的自定义实现了或者封装了事件的能力。

  2. 事件的来源

    事件的来源,分两个层面,一个层面 是规范中定义的来源,灵一个层面 是浏览器具体实现的队列。

    • 规范定义

      • dom源
      • ui用户接口源
      • 网络源
      • 导航和历史源
      • 渲染源

      这是几个主要的事件来源。

    • 浏览器的具体实现

      浏览器将不同的来源的事件,映射为自己的多个任务队列,并不是完全按照规范中定义的来源来划分宏任务队列的。至于优先级,浏览器有自己的优化和调度策略 比如用户交互高优先 防鸡鹅调度打捞低优先等等。

      这些队列,一般来说是依据优先级的大小来划分。

      • 输入事件队列 通常是最高优先级

        处理用户的交互,保证用户打字 滚动 点击 没有延迟

      • 计时器事件队列 普通优先级

        settimeout 等, 有限的优先级,定时器中的回调函数都放在这里等待执行。(settimeout实际是一个浏览器提供的api函数,它是一个同步执行函数,但是做的是异步调度的工作)

      • 普通事件队列 一般默认优先级

        最常用的队列 处理逻辑的主战场 网络 文件 数据 等等

      • 空闲队列 最低优先级

        requestIdleCallback

        事件循环完全空了 没事做的时候 来这里瞄一眼。

  3. 事件和观察者模式

    观察者模式是一种软件设计模式 。它定义了一种一对多的依赖关系。

    • "一" : 指的是被观察者。当它的状态发生改变时,它会对外发送通知。
    • "多" : 指的是观察者。它们一直盯着"被观察者",一旦收到通知,就会自动执行相应的操作。

    而事件机制,是对观察者模式的一个实现。

    我们写代码时

    • DOM 节点(如 button 就是 被观察者
    • 我们写的回调函数(function() { ... } 就是 **观察者 **。
    • addEventListener 就是整个观察者模式的核心api,它安排了一个或多个观察者去盯着被观察者。
    • 被观察者状态发生改变,触发通知
  4. 事件和DOM事件

    可能还是有不少朋友对事件这个概念有疑惑。

    事件 是归属于宿主环境的,请记住js语言中 并没有事件的概念。

    事件是一个信号,是系统内发生的任何值得注意的事情。比如:键盘按下了、图片加载完了、网络断了、数据到了。。。。。。

    DOM 事件 只是这个庞大信号系统中的一部分,专门负责**网页内容(文档)**层面的交互。

    之所以把DOM事件单独拿出来说,是因为它是我们在编写代码时,接触最多的一类事件。

    复制代码
    DOM 事件 是指发生在 HTML 文档元素(节点) 上的特定的交互瞬间。
    
    核心特征: 它们必须依附于某个 DOM 节点(如 <div>, <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> 和 <audio> 对象的播放状态。
    
    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 事件: 通常带有浏览器的默认行为

      • <a> 标签的 click 会导致跳转。
      • <form>submit 会导致刷新页面。
      • 键盘的 keydown 会导致输入文字。
      • 因此: DOM 事件提供了 e.preventDefault() 来阻止这些行为。
    • 非 DOM 事件: 通常纯粹是信息通知

      • XHRload 只是告诉你加载完了。

      • 因此: 非DOM事件通常没有 (但有例外)所谓的"默认行为"可供阻止。你调用 e.preventDefault() 没有任何意义。

  5. 事件和事件对象event

    通常来说,一个事件,之所以能成为事件, 要具有三个特质:

    • 遵循观察者模式
    • 携带事件的现场数据
    • 可观测的发生或状态的改变

    那么 携带事件的现场数据 ,这个就是要讲的event了。

    很多文章说,事件发生 比如鼠标被点击 马上就有事件对象被创建, 这个说法其实并不准确。

    严谨的描述 event 的创建时机:在事件被包装成任务 放入红任务队列排队 然后被取出开始执行,执行的第一步 是进行命中测试,确定事件发生的目标, 第二步,才是创建事件对象 。 第三步 是路径计算,确定传播路径。

    关于具体的流程,下面会详细讲。这部分作为综述,只是讲事件对象本身。

    在js层面的事件对象被创建之前,所有的相关信息,只是作为一个内存中的 c++ 结构体存在。

    那么 这里可以再给事件对象一个较为明确的定义:

    事件对象是浏览器将底层存有事件信息的 C++ 结构 包装成 JS 对象 ,并在路径计算前 完成创建,目的是为了让路径计算算法能读取其配置,并再气候的传播过程中充当一个携带现场数据及动态上下文的载体

    我们知道,以前的很长一段时间,前端的情况是 先有规范 再有实现 或者 先有实现 才有规范 或者虽有规范 但是实现不完全符合规范 ,总之是比较混乱,但是现在的情况已经好了很多,我们已经可以逐渐的信赖规范了。学习的时候 尽管实现上有些许的差别,但是可以用规范去加强理解。

    规范含义 :在 ECMAScript 相关的规范中,[[ ]] 形式的名字表示一种抽象的内部插槽,它们定义了对象在语义上的内部状态或行为。它们是规范用来描述对象如何工作的术语,不是 JS 层能直接访问的普通属性。

    实现层面:js引擎和浏览器会用各种方式来实现这些规范中定义的抽象的内部插槽。

    JS 提供的可访问接口 :很多内部插槽会通过公开的属性或方法提供出来(例如 event.typeevent.targetevent.bubbles 等,不止事件对象,js的其他对象也是如此。),这些公开接口并不是"直接读写了内部插槽",而是这些内部状态的一种通过api暴露出来的方式。

    因为事件对象可以说是事件中最重要的部分,所以,很有必要重点来学习,下面 我们用比较大的篇幅来详细学习事件对象。

    事件对象,从js的角度来讲,它确实是一个真正意义上的对象,我们平常从红皮书 或者权威指南上看到的js对象的定义,略有简化,请记住这个终极理解:

    js对象的本质 = 非原始值 + 属性记录集合 + 原型链继承 + 由内部槽/内部方法决定行为

    从这个角度来说, 事件对象完全符合js对象的本质定义。

    读过js红皮书的朋友也许记得,在不少章节中 都有 [[...]] 这样的内部属性的写法,也就是上面所说的内部插槽。

    我们首先介绍js事件对象的内部插槽:

    核心状态插槽

    定义在 Event 接口中,所有事件对象共用。

    内部槽位 类型 描述
    [[type]] String 事件类型(如 "click", "load")。初始化时设定。
    [[target]] EventTarget? 初始派发目标。在 dispatchEvent 调用时被设定。
    [[relatedTarget]] EventTarget? 与事件相关的次要目标(主要用于 MouseEventFocusEvent)。注意:它也参与重定位。
    [[currentTarget]] EventTarget? 当前正在执行监听器的对象。在传播过程中实时更新,派发结束后重置为 null。
    [[eventPhase]] Integer 当前阶段:0 (NONE), 1 (CAPTURING), 2 (AT_TARGET), 3 (BUBBLING)。
    [[timeStamp]] DOMHighResTimeStamp 事件创建时间(相对于 Time Origin 的高精度时间戳)。
    [[isTrusted]] Boolean true 表示由 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` 接口* | **内部槽位** | **描述** | |-----------------------|--------------------------------------| | **`[[key]]`** | 键值字符串(如 `"Enter"`)。 | | **`[[code]]`** | 物理按键代码(如 `"KeyA"`)。 | | **`[[location]]`** | 按键位置(如 `DOM_KEY_LOCATION_STANDARD`)。 | | **`[[repeat]]`** | 是否为长按自动重复。 | | **`[[isComposing]]`** | 是否在输入法(IME)组合过程中。 | ##### 结构化/底层实现槽位 | **内部槽位** | **描述** | |---------------------------------------------------|-------------------------------| | **`[[Prototype]]`** | 指向 `Event.prototype` 或子类原型。 | | **`[[Extensible]]`** | 对象是否可扩展。 | | **`[[NativePointer]]`** 或 **`[[EmbedderField]]`** | 这是js包装对象中存储的指针,指向底层C++ 的 原始对象 | 最后还有内部槽位通过对象属性对外提供的可访问的部分,即公开接口,在后面的部分会详细学习。 上面是出于对知识的完整性考虑,列出的表格, 在实际学习中, 前端开发者,了解到事件对象的插槽/槽位的深度,就已经是极限了,再继续深入学习,就是对应的c++结构,毫无必要。 而没有列出的path路径字段和内部槽位对外提供的可访问接口,后面会专门学习。 我们继续回到事件对象的创建,有两种创建方式: * 原生事件的创建 比如鼠标点击 网络事件 等等,这类事件,是在宏任务被取出,执行第一步命中测试,取得具体目标,**第二步** 创建事件对象时创建的, 一旦确定了目标元素,浏览器引擎(C++ 层,而不是 JS 引擎)就会实例化一个 `Event` 对象(例如 `MouseEvent` 或 `PointerEvent`)。这个对象是**宿主对象** ,它被填充了所有相关的上下文信息:`target`(刚刚找到的元素)、`currentTarget`(最初为 `null`)、坐标、时间戳、`bubbles` 属性等。(这些信息,原本是存在于值钱的c++结构中。) 这个时候,浏览器会在 JS 环境上创建一个 JS wrapper。这个 wrapper 和底层的 C++ 对象互相关联(wrapper 内含对宿主对象的指针/引用,就是上面表格中的**`[[NativePointer]]`** 或 **`[[EmbedderField]]`**,而宿主对象则通常保存一个对该js包装对象的弱引用或记录,以便于重复利用该js对象)。 至此,js已经有了事件对象,虽然是'包装对象',但是依旧是真正意义上的js对象。 * js创建的事件对象 是在js代码中**自己**创建的,通常使用 new ,在最新的红宝书第5版里,依旧在使用createevent的方式,已经不建议使用了。在自己new事件对象时,需要知道自己使用哪种具体事件的构造函数,因为每种具体的构造函数所拥有的内部槽位不同,无法混用或通用。 另外,js创建的事件对象,是同步创建的,执行到new代码,对象事件就立即生成, 这和原生的事件对象的创建不同。 自己new的事件对象 是纯正的js对象, 原生事件对象 是包装对象, 但是 他们都是真正的js对象。 事件对象的创建详细过程将在后面的事件的生命周期部分介绍。

在第一部分中, 介绍了事件中的一些重要的知识点。

重要的是eventtarget和event。

请注意,不要把这两个概念搞混淆了。

EventTarget 是一切的源头,它让某个东西,具备了事件处理能力。任何能处理事件的东西,都必须是已经实现了(继承也好 自己写也好 使用第三方库也好 )这个接口。

Event 是 一次事件的全部内容与状态的载体 它包含一次事件中的所有状态 (所有状态 所有关联到的对象 所有动态行为等等)

在这第二部分里,我们介绍事件的完整生命周期。

以一个物理点击事件为例,他的整个生命流程如下:

  1. 物理信号: 用户在硬件(例如鼠标或触摸屏)上按下。设备向操作系统 (OS) 发送一个硬件中断信号,并附带位置数据。

  2. OS 路由: 操作系统(例如 Windows、macOS、Android)接收该信号,确定哪个应用程序处于活动状态(即浏览器),并将此低级输入(例如"鼠标按下,坐标 X:Y")传递给浏览器的浏览器进程 (Browser Process)

  3. IPC 到渲染器: 浏览器进程负责浏览器的"外壳"(地址栏、选项卡),但它不知道选项卡内的内容。它通过进程间通信 (IPC) 将事件(例如 mousedown)和坐标发送到负责该选项卡的渲染器进程 (Renderer Process)

  4. 合成器线程接收: 在渲染器进程中,事件首先由合成器线程 (Compositor Thread) 接收。该线程独立于主线程(js运行的地方)运行,负责平滑地合成页面的各个层(例如,用于平滑滚动)。

  5. 合成器命中测试: 合成器线程执行一次"快速"命中测试。它检查事件坐标是否落在它标记为"非快速滚动区域"(Non-Fast Scrollable Region) 的地方。该区域是页面上附加了事件处理程序(如 touchstartclick 监听器)的区域 。

  6. 事件路由决策:

    • 如果事件不在 非快速滚动区域(例如,在可滚动的空白区域),合成器线程可以立即处理它(例如,开始滚动页面),而无需等待主线程 。

    • 如果事件 非快速滚动区域,合成器线程必须将该事件转发到主线程 (Main Thread),因为只有主线程才能运行 JavaScript 。

    • 在合成器线程的决策逻辑中,存在一个关键的性能瓶颈:当合成器线程发现触点位于"非快速滚动区域"(即绑定了 touchstart/wheel 等事件)时,默认情况下,它必须挂起 页面的滚动渲染,先向主线程发送事件信号,并同步等待 JS 回调函数的执行结果。

      为什么要等?因为浏览器无法预知你的代码中是否会调用 e.preventDefault() 来阻止默认的滚动行为。这种"跨线程的同步等待"一旦遇上主线程繁忙,就是造成移动端页面滑动卡顿(Scroll Jank)的根本原因。

      { passive: true } 的本质,是开发者向浏览器签署的一份**"异步执行承诺书"**。

      通过这个标记,你告诉合成器线程:"请直接开始滚动渲染,不要等我。我承诺在回调函数中绝不调用 preventDefault()。"

      一旦建立了这个协定,合成器线程就会立即 处理滚动帧(保证丝滑流畅),同时将事件以非阻塞 的方式发送给主线程去执行逻辑。此时,即便你违约在回调中强行调用了 preventDefault(),浏览器也会直接忽略该指令并在控制台抛出警告。

  7. 排队成为宏任务: 当事件(现在是 C++ 层面上的一个结构)到达主线程时,它不会立即执行。它被封装并放入红任务队列(也称为"任务队列"或"回调队列")中,等待执行。此时,它已成为 JavaScript 事件循环模型的一部分。

  8. 事件循环出队与任务启动: JavaScript 事件循环机制持续监控着状态。当主线程的调用栈为空 ,且微任务队列也被清空 (确保前一个循环彻底结束)时,事件循环才会从宏任务队列中取出那个排队已久的 mousedown 任务。 注意: 取出这个任务,标志着浏览器开始执行该任务内部包含的一系列逻辑

  9. 主线程命中测试(深度): 任务执行的第一步 是在主线程上进行"深度"命中测试。与合成器线程(只知道图层)不同,主线程拥有完整的 DOM 树、CSS 样式和布局信息。它使用这些数据(特别是"绘制记录")来精确确定事件坐标下最顶层 的 DOM 元素。这个元素将成为 event.target

  10. 创建事件对象: 一旦确定了目标元素,浏览器引擎(C++ 层,而不是 JS 引擎)就会实例化一个 Event 对象(例如 MouseEventPointerEvent)。这个对象(一个"宿主对象")被填充了所有相关的上下文信息:target(刚刚找到的元素)、currentTarget(最初为 null)、坐标、时间戳、bubbles 属性等。

    注意 :现在 浏览器引擎会让js引擎创建js层面的事件对象,就是把c++层的宿主对象包装成js层的事件对象。但是,浏览器出于优化的考虑,也许会采用懒加载的方式 在第11步完成后,按需让js引擎创建js事件对象。 不过从整个流程的合理性来说,可以认为此时 js事件对象也被创建。

  11. 确定传播路径: 浏览器根据 DOM 树结构计算事件的完整传播路径。这是一个包含从 window 开始,一直向下到 event.target 的所有祖先元素,然后再回到 window 的有序数组。

  12. 开始调度(捕获阶段): 任务现在开始沿着计算出的路径"调度"事件对象。它从 window 开始,向下 传播到目标,在每个节点上触发已注册为在捕获阶段运行({capture: true})的 JavaScript 监听器 。

  13. 目标阶段: 这是一个特殊的阶段。规范在实现上并没有一个独立的"目标阶段循环",而是将其拆解到了另外两个阶段中。

    1. 捕获遍历 到达目标时,浏览器会将目标标记为 AT_TARGET,并执行目标上所有 capture: true 的监听器。
    2. 冒泡遍历 开始时,浏览器再次访问目标,将其标记为 AT_TARGET,并执行目标上所有 capture: false(非捕获)的监听器。

    所以,实质上是捕获类监听器先执行,非捕获类监听器后执行同类监听器内部,才按添加顺序执行。

  14. 冒泡阶段: 事件随后从 event.target 向上 传播回 window,在路径上的每个祖先元素上触发标准的(冒泡阶段)JavaScript 监听器 。

  15. 任务完成: 一旦事件到达 window 并且所有处理程序都已运行(前提是没有调用 stopPropagation()),这个宏任务就完成了。

  16. 微任务检查点: 在事件循环查找下一个宏任务之前 ,它会立即执行并清空微任务队列 中的所有任务(例如,在事件处理程序中调度的 Promise.then() 回调)。

  17. 渲染: 在微任务队列清空后,浏览器现在有机会执行渲染更新(重绘页面)。

  18. 循环: 事件循环现在返回宏任务队列,以查找下一个任务。

下面,我们将以这整个流程为线索,介绍几个重要的步骤

从结构的角度来讲 物理点击 浏览器c++层创建初始结构 包装成宏任务入队列 被取出执行 命中测试 确定目标元素 浏览器c++层将初始结构和目标组合一起 创建了一个新的c++结构 填充槽位 浏览器调用js引擎 让js创建js层的事件对象 (包装了c++层的结构)填充关键槽位(可能有懒加载) 建立和c++结构的关联。

在具体的浏览器实现中, 有时会将第11步计算传播路径提前, 先计算传播路径 再开始创建js的事件对象。主要目的是可以通过先计算传播路径,确定是否有针对具体目标的监听, 假如没有, 那就根本没必要创建js的事件对象了。

介绍一下内部插槽的填充:

在命中测试完成 确定了具体的目标元素, 这个时候浏览器会创建一个c++事件实例结构,包括了最初的那个结构 又包括了目标元素,还有和事件类型相对应的内部槽位, 这是因为浏览器会根据事件的不同 调用不同 的构造函数 创建不同的c++事件实例。每种事件实例都有专属于自己的槽位, 同时还有通用槽位。

浏览器创建事件对应的c++事件实例,填充内部槽位,我们先介绍静态数据, 这些数据一旦填充,在整个生命周期就不会改变。以下以一个点击事件为例

  • [[type]] 根据事件类型(如 "click")硬编码 静态只读

  • [[isTrusted]] 值被设为true(因为是浏览器原生触发,如果是脚本模拟自定义 则为假)静态只读

  • [[timeStamp]] 读取当前的高精度时间 静态 (只读)

  • [[target]] 指向命中测试找到的最深层的那个 DOM 节点。 半静态 (Shadow DOM 中表现不同)

  • [[NativePointer]] 指向底层的 C++ 结构体地址。 静态 (内部引用)

  • [[screenX/Y]] 读取操作系统传入的硬件光标坐标数据。 静态

  • [[bubbles]] 根据事件类型查表确定(例如 "click" 默认为 true,而 "focus" 或 "scroll" 默认为 false)。 状态: 静态 (只读)

  • [[cancelable]] 根据事件类型查表确定(指示该事件是否允许通过脚本取消默认行为)。 状态: 静态 (只读)

  • [[defaultPrevented]] 初始化为 false。 仅当脚本调用 event.preventDefault()[[cancelable]] 为真时,该值才会被修改为 true状态: 动态 (可变)

  • [[propagationStopped]] 初始化为 false。 这是一个内部控制标志,当脚本调用 event.stopPropagation() 时被设为 true,用于通知事件分发器停止遍历后续路径。 状态: 动态 (内部标记/不可见)

  • [[underlying_platform_event]] 保存对原始底层硬件输入结构的 C++ 指针引用 就是最开始的那个初始c++结构。这实现了"零拷贝"机制,仅在 JS 访问特定属性(如 pressure, tiltX)时才通过此指针去读取底层数据。 状态: 静态 (内部引用)

注意 :此时,[[currentTarget]] 还是 null[[eventPhase]]NONE (0)

这里插一段,写这篇文章,一是自己需要对知识的总结归纳 二是希望写出来 是种分享,大数据时代 我们除了获取,不能忘记提供,关于木有图片。。。是因为我没有图床。。。其实就是懒。关于木有代码实例。。。还是因为懒。我喜欢用文字来描述来表达,虽然可能很多地方表达能力跟不上自己的想法。。。我是尽力了。其实我是有整篇的写作意图和明确的串联线索,只是写的多了,有时候就忘记或者是跑偏了,反正就是能力跟不上,反正就一个特点:字多。 大家当成小说看吧,其实我以前是写网文的。

我们学习到现在,已经能青春地意识到:

  1. "真身"在下层: C++ 层面的事件实例 才是真正意义上完整、权威的事件状态载体。它用有物理原始数据、DOM 传播的实时状态指针以及所有标准定义的内部槽位。
  2. "外壳"在上层: 我们在代码中操作的 JS 事件对象 ,本质上只是js 引擎创建的一个 代理壳 (Proxy/Wrapper)
  3. 核心连接: 这个壳内部并不直接存储大量数据,它最核心的东西是一个指向 C++ 结构的 内部指针 ([[NativePointer]])
  4. 数据获取的方式: 当我们在 JS 中访问属性时,并不是简单的读取内存,而是根据属性的特性,触发了不同的底层机制:
    • 实时透传 : 对于动态变化的数据(如 currentTarget, eventPhase),JS 对象通过 Getter 访问器 直接穿透到 C++ 结构中读取最新值。
    • 懒加载: 对于昂贵的计算属性(如 composedPath() 或标准化的 path),只有当 JS 第一次请求时,C++ 才会计算并将其转换为 JS 数组,然后挂载到 JS 对象上。
    • 缓存和优化: 对于静态不可变数据(如 type, timeStamp, isTrusted),js引擎可能会在第一次读取后将结果缓存在 JS 壳的"快照"中,以避免频繁跨越 C++/JS 边界带来的性能损耗。
  5. 可扩展性: 这个 JS 壳虽然是代理,但它也是一个标准的 JS 对象。因此,我们手动添加的自定义属性(如 e.myTag = "test")是保存在 JS 壳 自己的堆内存中的,C++ 层对这些数据一无所知。

下面开始计算传播路径

  • 浏览器使用内部算法, 从 [[target]] 开始,沿着父节点一直向上找,直到 window。这个过程会填充非常重要的 [[path]] 插槽。

  • 现在我们开始详细的介绍path内部插槽的构成和用途

    假如不包括 Shadow DOM,那么路径的计算和确定,将是非常简单的 沿着target一直向上找到window就可以了。但是正因为Shadow DOM的存在,让传播路径的计算成了一个微有难度的工作。

    简单的描述一下概念,不算严谨,但可以当作了解。

    一个dom树中, 一个元素被挂载了一个影子dom,那么 该元素被叫为 host, 然后,逻辑上看 以host为根, 有了两颗树, 一棵是刚挂载的影dom树 另一棵是host原本的子节点元素树。 而挂载的影dom树,并不是直接挂载 而是用一个root 挂在host上,root下面 才是影dom。 host原本的子元素树 叫光dom 。

    看这部分内容的朋友,应该是对shadow dom已经有了解的, 以上简单介绍,只是为了后面方便使用影dom 光dom host root 等名词。

    在第一部分,曾为了知识的完整性,列出了内部插槽的其他部分的列表。 这里这部分作为可跳过的选看部分,将详细的介绍path内容,这部分内容我个人认为在可跳过的内容中,算是重要的,所以打算用略大的篇幅来讲,不感兴趣的朋友依旧可以跳过这部分 。

    path中 是一个结构列表,每一项都是一个结构,对应着事件传播路径中的一个元素 严谨的说 对应着一个具备事件能力 即实现了targetevent接口 的对象。通过此列表,就可以观察到 本次事件的完整传播路径。 而且 事件的传播路径 是一次性创建, 创建好以后, 不会再更改,存在于事件的整个生命周期。传播路径是固定的,但是监听的调用等等还是会动态变化,这里只是讲路径的确定, 监听和传播过程 后面部分会详细讲。

    在最新的权威文档中,path中的每一项,都有7个字段,下面逐一介绍

    1. invocation target(调用目标)

      • 类型 :一个 EventTarget 对象(通常是 Node / Element / Document / Window,也可以是其它实现了 EventTarget 的对象)。
      • 描述: 这是该路径项对应的实际 DOM 目标。通常来说,就是当前的节点。
    2. invocation-target-in-shadow-tree (调用目标是否在 Shadow Tree 中)

    • 类型 : Boolean
    • 描述 : 一个布尔值,标记 invocation target 是否位于 Shadow DOM 树内部。
    • 作用 : 用于处理 Shadow DOM 边界的事件封装(Encapsulation)。此标志影响分派算法在决定重定位(retargeting)和阶段(capturing/at-target/bubbling)时的行为,以及是否需要将目标"影子化/重定向"给 shadow host 等逻辑。规范在处理路径和设置 eventPhasecurrentTarget 时会检查此值。
    1. shadow-adjusted target (Shadow 修正目标)

      • 类型 要么是 null,要么是一个 潜在的事件目标(potential event target)

      • 描述(最关键)

        • 这是"对监听器可见的那个目标(retargeted target)" 具体说,当事件从一个 shadow tree 向外传播(或在 shadow 边界处被观察)时,浏览器会把实际原始目标根据监听器位置做重定位 ,重定位后的对象就称为 shadow-adjusted target

        • 在事件分派过程中:如果path中的某个项的 shadow-adjusted target 非空,规范把该 struct 视为"AT_TARGET"类型的位置(用于设置 eventPhase = AT_TARGET),并用它来决定在该位置要以什么 target 值去调用监听器。

      • 举例 :若真实事件发生在 shadow 内部的某个 div,当在 shadow host(外部)上触发监听器时,shadow-adjusted target 可能是 host(或 host 的某个可见代理),而不是内部真实 div,从而实现了 Shadow DOM 的封装(retargeting)

      • 再举例 :当事件从影DOM 冒泡到光DOM 时,为了保持封装性,外部不应看到内部的真实节点。这个字段决定了在当前项所处的位置上,开发者调用 event.target 时应该返回哪个节点(通常是 host,即影子的宿主,而不是影dom内部真实的节点)。

      • 再再举例 算了,不举了

    2. relatedTarget (相关目标)

      • 类型 null 或 一个 潜在事件目标

      • 描述 用于那些有"related target"语义的事件(例如 mouseover / mouseout、焦点事件中的 relatedTarget 等)来记录在该路径层次上与当前 invocation target 相关联的另一个目标(经过 retargeting 后可能是不同的对象)。

        简单来说 就是类似于 shadow-adjusted target,但是专门用于修正 event.relatedTarget

        比如在 mouseover/mouseout 事件中,如果相关元素在 Shadow DOM 内部,这个字段确保外部只能看到 Shadow Host,而不是内部细节。

      • 注意relatedTarget 的值也会受到 shadow tree 封装/重定位规则影响

    3. touch target list (触摸目标列表)

      • 类型 / 含义 :一个"潜在事件目标"列表(sequence/list)List of Touch objects。主要用于触摸/多点触控相关的事件以记录与路径中该 struct 相关的所有触摸目标(比如 touchstart 的多个触点)。
      • 语义 / 用途 :在分派触摸/Pointer 类型的事件时,规范需要知道该路径层上哪些具体触摸点是相关的,以便在给监听器报告事件时能确定哪些触点属于当前 currentTarget 的上下文。
      • 专门用于触摸事件(Touch Events)。当触摸点在 Shadow DOM 内部移动时,需要修正触摸点的 target 属性,以符合 Shadow DOM 的重定标(Retargeting)规则。
    4. root-of-closed-tree (是否为封闭树的根)

      • 类型 / 含义 :布尔值。表示该 struct 表示的 invocation target(或其相关信息)是否处在一个 closed shadow tree 的根(即该 struct 表示的那一层涉及到一个 closed shadow root)。
      • 描述 : 标记该路径项是否是一个模式为 closed 的 Shadow Root。
      • 如果为 true,则在使用 composedPath() 获取路径时,路径会在这个节点被截断,外部无法通过 API 获取到封闭 Shadow DOM 内部的节点。
      • 这个标志用于实现 closed shadow tree 的封装保护:当 root-of-closed-tree 为真时,规范在构建对外暴露的 invocation target 列表或在清理路径时会采取特殊处理(例如阻止 closed tree 内部节点出现在 composedPath() 的对外结果中,或在路径清理时决定是否插入清理 struct 等)。通俗说:它帮助浏览器决定"哪些内部节点必须对外屏蔽"。
    5. slot-in-closed-tree (是否为封闭树中的 Slot)

      • 类型 / 含义:布尔值。表示在路径构建时当前节点是不是"一个处在 closed shadow tree 中的 slot(slot-in-closed-tree)"的上下文标记。
      • 语义 / 作用 :与插槽(<slot>)与被插入的 light DOM 元素相关的路径构建有关。规范在把路径 append 到 event.path 时把这个标志一并记录,以便 later 在决定 clearTargets、retargeting、以及是否把某些 struct 暴露到对外路径(或触发 activation behavior)时使用。简单说,它用于正确处理插槽 + closed shadow tree 的组合场景
      • 同样用于控制 composedPath() 的暴露范围,确保封闭树的内部结构不泄露。

    以上7个字段,是规范规定的path中的字段,属于内部使用的数据,在我们的js层,并不能直接使用。

    新的一天,有点忘记进度了,上面讲了path的7个规范定义的字段。目的是为了下面讲传播路径的计算。前面好像也讲过, 如果是没有影dom,那么从命中具体目标以后,直接网上挨个找爸爸,挨个填path中的项。很简单。 但是因为有了影dom,路径的计算有点繁琐。

    那么被插槽进影dom的光dom元素,发生的事件它的路径如何呢?

    如果composed为真,此被slotted的元素上发生事件,路径为 此光dom--影slot--影root--光host--Document

    如果composed为假,路径依旧是 此光dom--影slot--影root--光host--Document

    这是因为规范规定:

    composed:false 只是 一个必要条件 ,但不是充分条件;还要满足 "该 shadow root 是事件目标所在根" 这个前提,才会被拦截返回 null(从而阻止继续向上到 host)。

    如果事件目标的根不是该 shadow root(例如目标属于 light tree,其根是 document),那么该 shadow root 不会返回 null,而是返回 host ------ 事件继续传播。

    也就是说,被插槽进影dom的光dom元素, 依旧归属于光dom树,在它身上发生的可冒泡事件,在影dom它的slot位置开始,经历 此元素---slot---root 达到影dom边界,此时 规范定义了判断算法,必须要满足两个条件,才会被拦截, 一 是 composed为假 二是 该发生事件的元素的根是影root, 这样才会被拦截。

    被插槽进影dom的光dom元素,归属于光dom树, 它的根为document, 而不是影root,所以不满足拦截条件。

    很多资料或者文章把composed为假的情况 绝对化,从规范上说 是不对的。

    对于影dom内部的元素发生的事件,composed为假会拦截,因为他们同时符合根为影root的条件。

    但是对于归属于外部光dom的被插槽元素来说,它的根为document,不符合条件, 所以不会拦截。

    上面第4个 relatedTarget 比较有意思,可以稍微了解一下

    • 含义:对于 mouseover/mouseoutfocusin/focusout 等"有关联目标"的事件,记录相关目标。

      这个字段,并不是所有事件都具有,一般是有节点间转移的动作的事件才有。

      假如有元素a和b,鼠标此时在a上,现在,把鼠标从a移到b,那么,对于a来说,在它身上发生了mouseout事件,鼠标离开, 创建这个事件对象的时候,path路径中,target当然是a,而relatedtarget表示关联目标,就是和target对应的一个目标,因为鼠标是移动到了b身上,所以relatedtarget就是b。

      如果我们从b的角度来看,在b的身上发生了mouseover事件,鼠标到了b身上,那么这个事件对象创建,它的path路径的target是b, 和它关联的目标 relatedtarget则是a,因为鼠标从a过来的。

      请注意,relatedtarget 也遵守 影子DOM 的重定位规则。 从影dom外面看,如果 relatedtarget 指向的是 影DOM 内部的元素,它也会被替换为 Host

    • 用处:

      1. 在调用监听器时提供上下文(比如判断鼠标是从哪里移进来的)。
      2. 作为事件触发的裁决 依据:浏览器会对比重定位后的 targetrelatedtarget。如果在某一层级,两个变成了同一个对象(例如都变成了 host),浏览器会认为没有发生实质性的交互,从而阻止该事件在这一层的触发。

    上面第3个 shadow-adjusted target (Shadow 修正目标)

    shadow-adjusted target

    • 含义:对于当前项 来说的目标,请注意,这个target是path中的一个字段,要和在event事件对象的内部插槽中,还有一个静态的原始目标对象相区别 不要搞混。通俗的解释:在影dom中 path中的target始终是发起事件的那个真正目标。 而跨出影dom后 该target变为host, 以host代替影dom内真正的事件发起目标,以实现不让外人偷窥到影dom内部情况的效果。也就是说,如果不存在影dom,则该target 始终都是事件的实际发起元素。 而在影dom中的项目上,也是事件的实际发起元素,但是越过root,在host这一项上,该target变为host,并且一直保持到window项。

      请注意

      对于被插槽进影dom的光dom元素,因为它依旧归属光dom,所以在影内 影外 host上,它的该target值都为真正的光dom本身。

    • 用处:在该项上的监听器中读取 event.target时要显示的对象。

      当该项表示的事件对象为被插槽 进影dom中的时候,无论在何处 其值为真正本体。

      当事件源是影dom内部元素,该项位于影dom外时,显示host,位于影dom内时 显示真正事件发起目标, 位于host时,显示host。

    经过前面的大段铺垫,现在开始学习传播路径构建 这次是真的真的了。

    之所以执着于大段的讲path的字段和事件对象的内部插槽,主要是我个人认为,js层面的公开的api,只是对内部数据的整合和包装,只学习他们,无法真正准确的了解事件传播路径的算法和之后的事件传播及处理,以及这些内部数据和标志位之间的配合所带来的对于外部来说 比较不好理解的现象。 当然 限于能力,写的比较散乱,从开始到现在 一大半都算超纲注水,所以要赶快回归。 观看时跳过上面的这一大部分就好了。

    事件传播路径的构建,是一个算法,它是以当前 DOM结构为基础的,不需要JS参与的行为。完全依靠dom结构,加上影dom的边际规则,逐步构建出来一条物理路径。所以 直到最后路径构建完成,我们都看不到监听等js的一根毛。 就类似于 传说中的低耦合,甚至是解耦,没耦。 路径的构建,基本上是浏览器引擎的活, 至于以后监听什么的,和路径没关系, 不管你听不听 路就在那里。不管你走不走,路也就在那里。 以后,js的监听和动态的设置活动,属于逻辑上的, 而现在构建出来的路径,属于物理上的。只需要注意 一点 当前节点的状态,有可能是由之前节点创建时的js层面参与决定的, 但是也仅此而已,路径的创建,js一直是靠边站的。反正,就是这么个意思吧。

    其实吧 写到这里 有点沮丧。。。 也就是一个遍历算法,做了那么多的铺垫,早点直接说不就得了吗。然后我又想到前几天刷到的小片段: 入职以后前三个月每月工资2000,第四个月开始4000, 大智慧的朋友说 那你等到第四个月再去入职。

    继续码字

    这里就不得不引出两个比较重要的概念:

    • 合成/组成树 扁平树( Composed Tree flat tree )

      虽然名字是两个 但都是指的同一个东西,组成树的意思主要突出它的来源并不单一,比如光dom 影dom slot 等,然后根据规则组成的一棵树。

      扁平树的意思是从组成以后是一个整体的角度来讲的, flat表示消除了原本的影dom和光dom的隔阂,把正确的slot的内容投影拍扁进影dom槽中,表示是一棵单一的连续的树。

      注意 归属并没有改变。

      扁平树并不是一棵完整的或者部分的真实存在的树,它是一种规范中抽象定义,在实现中,逻辑存在,在需要时,动态计算出来的一种逻辑树。

      从名字 从存在形式 都已经说了,那么 它的内容是什么呢

      扁平树是以整棵 DOM 树为基础,但在遇到宿主host时,会使用其 影dom结构来替代原本的内容,同时将光 DOM 中被选中的节点"投影"进 影DOM 的插槽中,最终形成的一棵树。

      那么这里要特别注意,在物理上,并没有什么变化,host下依旧是一棵光dom 一棵影dom, 扁平树是一种抽象的逻辑树,按照规则 把它需要的东西 提取出来。物理上 原来怎么样 现在还是怎么样。

      ---------为了说清这扁平树 我可费了老大劲,改了好多遍。

    • 渲染树

      渲染树是基于扁平树,使用css规则,生成的用于布局和绘制的树。

      好像暂时用不到渲染树,先不详细说了,后面讲到渲染再说。

    dom树 物理存储结构,只有原始的层级。

    扁平树 抽象出来的 , 打通了光dom和影dom的隔阂 有逻辑层次结构 。

    渲染树 扁平树加css规则 有视觉呈现结构。

    事件传播路径的构建算法,大约有百分之七八十的内容,都以零散的方式在前面介绍过了,还剩一个系统性的算法描述作为总结,但是有点犹豫,因为虽然算法很简单,但是牵扯到的字段和标志位比较繁琐,需要比较大的篇幅来讲述,而这部分内容 在前端开发中,百分之八九十的可能性是用不到的。但是在写组件写库写shadowdom以及排除一些bug的时候,却是神兵利器。所以打算放在第三部分事件的传播和处理部分再详细介绍。

    现在我们来思考一下,就是真的只用脑子思考,在写这整篇文章的时候 有些知识点 ,甚至在有些地方多次反复的强调它所处的阶段 所在的位置等等,就是不断的试图在读者的脑中 构建出一个完整的事件模型,说到流程,你可以想到主要步骤, 说到光dom影dom 你可以想到一棵树。

    一棵dom树,某个节点被挂上了新的树,于是 这个节点成了host,下面有了两棵树 一棵光dom 一棵影dom。 这里有个问题,就是以哪棵树为着眼点,不少朋友认为,光dom是正宗嫡系,当然要以光dom为着眼点,关注上面有没有被slot等等, 其实是不恰当的。 要以影dom为主,以影dom的角度来看, 影dom被挂上来,就是接管 代替了光dom,我就是老大 看我的。 你光dom想干点啥,必须投影到我这里,你现在就是我的备件仓库/展销厅, 我给你机会,你才能出现。

    这是一种思考模型,从物理dom树 到扁平树的转变,虽然光dom始终是host的子树,是物理存在的,但是在思考时 使用扁平树的角度, 因为扁平树和渲染树对光dom是默认忽视的。

    那么 我们再稍微延申一下, 假如光dom树 没有被slot, 然后 我在js层面手动派发事件,会出现什么情况? 我在以前刚开始学习时,曾误以为,传播路径有两条 一条物理的 一条扁平的,后来才纠正过来,始终就是一条路,依靠算法来决定怎么走。

    依旧是路径构建算法,算法规定,A node's get the parent algorithm, given an event, returns the node's assigned slot, if node is assigned; otherwise node's parent. 就是没有被slot的节点找到的父亲是物理链路上的父亲。

    也就是说 事件会传播到host 然后继续传播到document。 js可以对它像对其他元素一样 进行操作。包括监听 派发 允许slot等等等。 唯一的问题 就是因为它没有进扁平树 也就进不了渲染树,在视觉上是不在的。 看不到 所以除了设置slot加入扁平树的操作以外, 其他的操作 要慎重,避免各种bug的产生。

    影子dom也挺有趣的,给事件传播路径上增添了很多色彩,影子哥和事件传播路径的构建有关的内容,好像也差不多了。如果后续其他部分里还牵扯到Shadow DOM的内容,到时候想起来再讲吧。

    这第二部分马上写完了,最后总结升华一下

    记得在第一部分,提到了观察者模式,我们再再再的把它具体到事件上来。

    事件的发生,不管是因为什么原因,它的本质,就是 期待关注 。

    我有事,我有事啊,我说我有事了,这是事件的发生,我不需要知道谁会处理它,也不知道什么时候会被处理。

    三个阶段,捕获 目标 冒泡 提供了时间上的选择权 和处理的策略

    捕获是拦截和预处理

    目标是现场自己的处理

    冒泡是兜底和总结。

    传播路径,提供了空间和层次上的哨位 路径上不同的哨位,有着自己的不同的职责和环境,他们看待经过自己的事件,是用自己所在的哨位职责来观察的,同一个点击事件,在buttnn上看,是用户点按钮了, 在form上看, 是用户可能在提交表单, 在document上看,是用户还活着 没噶呢。 就是说 你可以选择 在具体哪个抽象层级上来处理这个业务洛基。

    而三个阶段和传播路径的结合,就是对于事件处理的从时间到空间到层次上的结合,择优选择,比如事件委托该放在哪里? 时间上,当然是选择到最后的冒泡了,空间和层次上 当然是选择越高越好了 大内总管那岗位厉害。而每个哨位 也有自己的小权力 比如这事归我管,不往上送了,比如这事只归我管 不给周围同事了。

    最后 我们进入玄幻模式: 事件流为我们提供了时间空间层次三维立体的选择权。

三、 事件的传播和处理

简介

我们将介绍事件的传播和处理, 这部分的内容,相关的介绍 文章 帖子 可谓是汗牛充栋栋栋栋栋。。。但是 基本上都是先讲三个阶段 然后扔出几个api 然后几段示例代码 然后总结一下 完事。 相信有很多朋友 看过以后 感觉是看了一些什么,但是仔细想想 又似乎是什么都没看,毫无获得感。其实这就是因为很多文章 都是知识的罗列,就像 教你说 哎 小明 你看 你按一下这个开关 灯就亮了 再按一下开关 灯就灭了。 但是 只有罗列 没有知识之间的桥梁 没有构建出一个合适的思考模式,只是浮于表面的认知。 你按一下开关 灯没亮 哎呀 咋回事嘞? 或者按一下开关 灯没灭 或者你按一下开关 砰的一声 灯炸了 只能夸它炸的响 不知道它为什么炸 。我尽量不走寻常路,从其他的角度,我们一起来学习事件的传播和处理。风格依旧跟第一部分和第二部分一样, 没图没码 网文风格,但是对描述和表达的准确性 依旧值得信赖,我会力求表达准确 符合规范 贴合实现。

复习EventTarget

第一部分讲了事件的一些重要知识点,第二部分讲了事件的完整生命周期,并且用了大量篇幅介绍了传播路径的构建和shadow dom 以及事件对象。

这是第三部分

前面两部分是纯练内力,这部分有点内力外放的意思。现在我们一起修炼吧。

一切的源头是 eventtarget,前面已经多次提到,想具备事件能力,必须实现eventtarget接口。

eventtarget是一个接口 一种能力 一个对象。 从对象角度来说, dom事件中的那些节点,基本上都是以原型链的形式默认继承了eventtarget这个对象。

在前面第一部分, 我们曾给出了一个js对象的新的定义:

js对象的本质 = 非原始值 + 属性记录集合 + 原型链继承 + 由内部槽/内部方法决定行为

eventtarget作为对象,那就必然可以用这个定义来解释。

一个实现了eventtarget接口的对象,具备了事件能力,那么将它用上面的定义来解释:

  • 非原始值:它当然是个对象引用。

  • 原型链继承 :打开 F12,在控制台里敲入一行命令:console.dir(document.createElement('div')) 回车之后,会得到一个纯净的 div 对象。

    顺着它的 [[Prototype]](或者 __proto__)一层一层往上翻 从 HTMLDivElementHTMLElement,再到 ElementNode 再然后,就看到我们的主角了 EventTarget

  • 属性记录集合:当然可以随便添加属性。

  • 现在还剩下的就是最重要的了 ------ 由内部槽/内部方法决定行为

EventTarget 内部,有一个js层面看不到的内部槽位 。 规范中它的名字叫 Event Listener List事件监听器列表)。

正是因为有了这份列表,它才从普通的 JS 对象,进化成了一个拥有事件能力的对象。

因为它是对象内部的一个属性/槽位, 可以这样表示 [[eventlistenerlist]]

从名字就可以看出,它是一份列表,由每一条列表项组成。

下面我们介绍一下每份表项的构成,你就会明白了。

事件监听列表的组成

跟全文的第一部分和第二部分一样,依旧延续哨位这个比喻。

事件监听列表,每个表项 ,由7个字段构成。

  1. type
  • 类型:字符串 (String)
  • 含义 :这个表项具体负责哪块业务?是 click 组的,还是 keydown 组的?
  • 作用 :这是最基础的索引。比如当类型为click时,只有 type 为 "click" 的表项会被调出。
  1. callback
  • 类型:函数 或 对象 (EventListener Object)
  • 含义:具体干活的回调函数。
  • 细节:通常我们传的是一个 JS 函数。规范里讲的,也支持传一个对象,似乎很少使用。
  1. capture
  • 类型:布尔值 (Boolean)
  • 含义这是最重要的身份标记之一。
    • true:属于捕获组。事件从 Window 往下传的时候,就要注意了。
    • false:属于冒泡组。事件从 Target 往上冒的时候,就要注意了。
  • 核心规则:它是"去重复算法"的三大要素之一。同一个函数,如果分别注册了捕获和冒泡,那是两条完全独立的表项。
  1. passive
  • 类型:布尔值 (Boolean)
  • 含义 :这是一个关于性能 的字段。
    • true:该表项签署承诺书,保证在执行过程中绝不调用 preventDefault()(不拦路)。
    • false:保留拦路的权力。
  • 有默认 :浏览器为了保证移动端滚动的丝滑,对于 touchstartwheel 这种高频事件,会在Window、Document 等顶层对象上 默认帮你勾选为 true,以便合成器线程能直接渲染滚动帧。
  1. once
  • 类型:布尔值 (Boolean)
  • 含义一次性用品。
    • true:干完这一票就走人。
  • 机制:当这个回调被执行之后,哨位会自动把这份表项从列表中物理删除。
  1. signal
  • 类型:AbortSignal 对象
  • 含义 :这是 AbortController 带来的新机制。
    • 机制 :你把一个遥控器(Signal)交给哨位。以后你想离职,不用专门跑一趟(调用 removeEventListener),你只需要在外面按一下遥控器(调用 abort()),哨位里对应的列表项就会自动销毁。
    • 细节 :如果你递交申请的时候,手里的遥控器显示**"已引爆"**(aborted),是根本不会受理你的注册请求的。
  1. removed
  • 类型:布尔值 (Boolean)
  • 含义这是唯一的内部专用字段,开发者不可见。
    • 作用:解决同一事件中的并发问题。
    • 场景:当在同一个事件派发中,在当前正在工作的哨位上,如果前一个表项中的回调,把后面的表项移除了,此字段起作用。
    • 逻辑 :为了不打乱正在进行的循环索引,哨位不会立刻移除表项,而是悄悄在这个字段打个勾(removed: true),类似于软删除。等轮到被打勾的表项的时候,直接跳过,既不执行也不报错。

看到这些字段,是不是感觉特别熟悉? 注意 这些字段 现在是在eventtarget中的内部槽位中,仅限内部使用, 那么,它们是怎么被外面js层改变设置的呢?

eventtarget的对外窗口

前面反复的说 eventtarget是事件机制的源头,必须实现这个接口 才能具备事件的能力。dom事件中的节点,都是默认通过原型链继承了eventtarget对象。 那么,我们在js层面,如何使用呢?

eventtarget提供了三个api给我们,这也是它的核心功能,注册 销毁 触发。

前面讲过,一个事件 之所以能成为事件, 要具备三个特质:

一是遵循观察者模式,二是携带现场数据 三是可观察的变化。

那么eventtarget提供的这三个api,之所以是核心能力,就是因为用这3个api,实现了观察者模式。

addeventlistener注册 添加观察者

removeeventlistener退订 移除观察者

dispatchevent触发 发布者发布

这三个api,加上event,构成了大部分前端开发者的事件机制的知识体系,那么 假如再加上 事件监听列表 。。。。。。你就功力大涨,凝聚金丹进阶了。

1. addEventListener

给元素添加事件,经历过三个时期:

  • HTML 属性绑定 (Inline Event Handlers)

    这是最早期的 web 开发形态,直到现在,依然可以在很多老旧系统或者为了图方便的 demo 中看到它的身影。

    这种方式, 虽然看起来简单,直接把代码写在标签里,但它背后发生的事情其实非常不科学。

    比如 <div onclick="console.log(id)"> ,浏览器并不是直接运行这段代码。 浏览器引擎在解析 HTML 时,会把onclick属性里的字符串console.log(id)提取出来,然后动态创建一个函数。它通常使用了一个在现代 JS 中已经被强烈建议不再使用的 with 语法,强行扩展了作用域链。

    浏览器生成的代码逻辑大致如下(伪代码):

    复制代码
    function(event) {
        with(document) {
            with(this.form) { // ...
                with(this) {
                    // 自己的代码被包裹在这里了
                    console.log(id); 
                }
            }
        }
    }

    这就是为什么这种方式,可以直接在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="console.log(id)"的方式内联注册了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(追加) 。 它创建一个新的表项,填好 typecallback,然后直接把它挂在列表的末尾

    它的优势:

    1. 无限叠加:你可以添加无数个监听器,它们和平共处。当事件触发时,浏览器会按照列表中的顺序(也就是注册的顺序),依次执行它们。

    2. 精细化控制:这是 DOM0 做不到的。

      • 你可以控制是在捕获阶段 触发还是冒泡阶段 触发(通过 capture 选项)。

      • 你可以控制它是否只执行一次(once: true)。

      • 你可以承诺不阻止默认行为以提升滚动性能(passive: true)。

      • 你可以随时用信号终止它(signal)。

    那么,是不是真的可以无限叠加任何监听呢?并不是。

    为了防止你因为代码逻辑混乱或者一时糊涂手抖而重复注册同一个函数,浏览器在追加之前,会有一个严格的查重机制

    这道机制只认三个字段:

    1. type(事件类型)
    2. callback(回调函数引用)
    3. capture(捕获状态)

    请注意,只有这三个! passiveoncesignal 这些后来加入的参数,不参与去重判断。

    就是说 如果你先注册了一个 { 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 就是用来把观察者从列表中请出去的。 它的工作很简单,就是使用上面提到的那三个字段去列表里找人:

  1. type

  2. callback

  3. 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 时:

  1. 浏览器找到了对应的表项。
  2. 不会立即把它从内存里删除。
  3. 它只是悄悄地把该表项的 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. 梳理线索 整理思路

    现在,我们来快速梳理一下我们已经学过并掌握的知识脉络

    • 事件对象

      事件的三个特质 ,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)。

      极其重要的内部槽位

      • [[Path]]传播路径列表

      传播路径列表存储在 Event 对象的 [[Path]] 插槽里。 它是静态 的。一旦派发开始前计算完成,它就锁死了。即使你在某个回调里把父元素删了,事件传播依旧会沿着已经计算好并岁锁死的路径传播。

      列表中的每一项 不是简单的 DOM 节点,而是一个结构体,包含以下7个字段:

      1. item (当前哨位)

        具体的 DOM 对象(Window, Document, Element, ShadowRoot 等)。 这是 currentTarget 在当前的真实指向。

      2. target (Shadow 修正目标)

        关键数据。这是算法预先计算好的、在当前的哨位应该对外暴露的 event.target

        逻辑:如果当前哨位是 Shadow Host,这里就是 Host;如果是在 Shadow DOM 内部,这里就是真实的内部节点。(为了封装性而撒的谎)。

      3. relatedTarget (Shadow 修正关联目标)

        同上。预先计算好的、对外显示的 event.relatedTarget

      4. touch target list: (仅限 Touch 事件)

        经过 Shadow DOM 边界修正后的触点列表。

      5. root-of-closed-tree

        布尔值。标记该路径项是否是一个 closed 模式的 Shadow Root。用于隐私保护。

      6. slot-in-closed-tree

        布尔值。用于处理复杂的 Slot 分发场景。

      7. invocation-target-in-shadow-tree

        布尔值。标记当前哨位是否位于 Shadow DOM 树内部。

    • 节点上的监听列表 (The Listener Lists)

      虽然它们是即时读取的,但它们客观存在于每一个 DOM 节点上。

      • 持有者 :每一个实现了EventTarget 接口的dom对象。
      • 数据结构:事件监听器列表。
      • 每个列表项包含字段
        • type (事件类型)
        • callback (函数或对象)
        • capture (捕获标记)
        • passive (性能标记)
        • once (一次性标记)
        • signal (引爆销毁信号)
        • removed (软删除标记 - 初始为 false)
  2. 派发与回调调用

    经过上面的快速梳理 ,我们已经知道,有三样最重要的东西 事件对象 传播路径表 传播路上的每个节点的监听列表。

    现在我们开始发车吧,开启一段有趣的旅程。

    嘀嘀嘀 喇叭响了,浏览器引擎启动了主循环,这辆车,要跑两个半程。

    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 的逻辑阶段都宣告结束。

      此时,浏览器引擎会做最后的清算(注意:停止传播不等于取消默认行为):

      1. 返回值生成dispatchEvent 会返回一个布尔值。

        • 当且仅当事件可取消(cancelable: true) 且至少有一个监听器调用了 preventDefault() 时 返回 false
        • 否则 返回 true
      2. 默认行为

        • 引擎只看 [[canceled flag]]

        • 哪怕传播在第一站就停止了,只要没人反对(调用 preventDefault),浏览器依然会执行默认行为(如跳转链接、提交表单)。

    此时,同步的 dispatchEvent 调用栈清空并返回。

    微任务开始了。

一些重要知识点详解

  1. 在某个节点,浏览器是如何知道当前所处的阶段?

    当事件传播来到某个 哨位/节点/标签/实现了eventtarget接口的对象/dom元素 , 当前哨位里,是有原始的事件监听器列表,并没有当前事件动态走向的所处阶段,那么浏览器是怎么得到这个阶段呢?

    很多朋友会说:引擎当然知道 它就是boss 啥都知道,咱只要知道它知道就行了。

    话是不错,可但是,我们还是有必要了解一下的。

    在事件传播时随身携带的event对象中,内部插槽[[path]]存着计算好的路径,每条路径,都是一个列表,里面有7个字段。

    引擎使用这种存储方式 Path[0] = target Path[last] = window 来存储需要走的半程

    • **捕获循环 **:
      • 引擎设置 iPath.length - 1 (Window) 开始,递减到 1 (Target 的父亲)。
      • 只要循环在这个范围内 ,引擎就强行把 eventPhase 设为 CAPTURING-PHASE (1)
      • 只要没走到索引 0,且我在倒着走,那我就是在捕获阶段
    • 目标循环 :
      • 引擎设置 i = 0
      • 只要 i 是 0 ,引擎就强行把 eventPhase 设为 AT_TARGET (2)
      • 我踩在终点上了。
    • 冒泡循环
      • 引擎设置 i1 (Target 的父亲) 开始,递增到 Path.length - 1 (Window)。
      • 只要循环在这个范围内 ,引擎就强行把 eventPhase 设为 BUBBLING_PHASE (3)
      • 我已经离开索引 0 了,且我在正着走,那我就是在冒泡。

    so 并不是 phase 决定了怎么走,而是 "怎么走"决定了 phase。

    引擎使用Path,通过控制遍历的起点、终点和方向,从而精准地定义了当前的"时空状态",这就是它为什么在某一节点,能用自己知道的 所处阶段,去和节点内部的原始事件监听列表的快照进行对比核查的原因。

    即使在Shadow DOM存在的情况下,path依然正确有效。

    比如需要确定target时

    event.target = Path[i].shadow_adjusted_target

    • 如果 i 在影内,修正目标字段里存的就是内部节点。
    • 如果 i 在影外,修正目标字段里存的就是 Host。

    总结就是

    浏览器引擎确定状态的方式,不是"动态感知",而是**"读取预设"**。

    • 所处阶段:由循环索引决定。
    • 当前哨位所能看到的目标:由 Path 里的预存字段决定。
    • 当前节点:由 Path 里的 item 字段决定。

    这就是为什么派发算法如此高效------因为它不需要思考,只需要查表

  2. 在某哨位 对比核查事件监听器列表时,是全部核查完毕,然后依次执行,还是核查出来一个,就执行一个?

    这是严格按照,揪出来一个 就执行一个的方式。

    这里有一个极易产生的误解。很多朋友认为浏览器是先把快照里的所有人都撸了一遍,挑出合格的,组成一个新的待执行队列,然后一口气执行完。这是错的。

    浏览器的执行逻辑,是严格的 "揪出来一个,处理一个"串行模式。

    for 循环的每一次迭代中,引擎做的事情是完整的闭环:

    1. 点名 :根据索引 i,从快照里指向第 i 个监听器。
    2. 立即核查
      • " 你现在被 removed 了吗?" (检查 removed 标记)
      • " 你的 signal 炸了吗?" (检查 aborted 状态)
      • " 你是这个阶段的吗?" (检查 capture/phase)
    3. **立即执行 **:
      • 如果核查通过,立刻、马上、同步调用你的回调函数。
      • 之所以说是 串行,是因为 回调函数的执行,是控制权的移交,必须由js引擎来干活了。浏览器引擎先去抽根烟了。
      • 注意 :此时,第 i+1 个监听器还在队列里等着,所有人都不知道它合不合格。
    4. 后果
      • 正因为是"执行完一个"才去"找下一个",所以当前这个回调函数里的操作,能直接决定后续监听器的命运。
      • 比如你在第 i 个回调里调用了 stopImmediatePropagation(),引擎在准备进入 i+1 循环之前一检查:"欸,熄火标记亮了?" duang的一声,循环直接 break,第 i+1 个监听器连核查的机会都没有,大家直接散伙。

    总结就是: 浏览器不是"批处理",而是严格的"单步迭代" 。 快照保证了**"人员名单"** 不许变(后面新来的进不来),但**"生存状态"** 是每一次迭代时实时核查的。

  3. 在某个节点上,是 1 对 1 还是 1 对 N?

    假如在某个子元素(比如按钮 B)上发生了一个点击事件。事件一路火花带闪电,来到了顶层节点(比如容器 S)。 此时,容器 S 上注册了好几个 click 类型的监听器:有的负责挖坑,有的负责埋雷,有的负责点火,但他们都属于click类型。 那么问题来了:当事件传播到 S 时,是"精准命中"某一个回调执行?还是所有相关的回调都会被执行?

    很多朋友会脱口而出,当然是 1 对 1:"我明明是点的按钮 B,浏览器应该很聪明,只执行那个我当初注册的那个处理 B 的回调吧?"

    正确的答案是 浏览器引擎执行的是 1对 N

    还不是很明白的朋友,可以先看一下前面的 派发与回调调用 这一部分内容。

    当事件传播的车开到顶层节点 S 时,浏览器引擎拿出 S 的监听器列表(快照),开始选人干活。 它的筛选标准非常简单粗暴:

    1. Type 对吗? (事件是 click,你监听的也是 click 吗?对。)
    2. Phase 对吗? (我是冒泡过来的,你是监听冒泡的吗?对。)
    3. Flag 正常吗? (没被 remove 吧?signal 没炸吧?正常。)

    只要这三条符合,不管你回调函数里写了什么,统统揪出来干活

    它的策略就是:全部唤醒,依次执行

    那么 怎么办呢? 当然是在回调函数里判断了,除了有些业务逻辑需要来着不拒,比如访客点击,每个点击都要记录,不需要加判断,除此以外,第一行代码都是身份判断 因为如果不判断,作为回调函数来讲,不管谁的点击事件来了, 它都得执行一遍。

    而作为事件本身来说,它只希望自己期望的回调被执行,其他的回调必须拒绝它。

    对于基于事件委托的业务逻辑来说,第一行代码永远都是身份判断,

    所以,回调函数里的身份判断,万万少不得。

    这里我们再引入一个狠角色 stopImmediatePropagation

    一个点击事件,可能会有几个点击事件监听项在等着,当某个监听项调用了stopImmediatePropagation, 好了 都别等了 立刻散伙收工。那么问题又来了,假如有好几个监听项在排队, 我不能精确的保证 该在何处调用这个api?这又是一个问题,所以 要保证你所期待的那个监听项是排在第一 或者是你可以明确的知道 应该在哪里调用

    比如 两个点击事件项 A是校验 B是提交 你校验不过,可以直接祭出大杀器stopImmediatePropagation,立即阻止了B的排队执行。

    其实这个函数通常在第三方库里使用,因为那些库的初始化 都是先于用户代码,所以库在初始化时会抢先注册监听,通过在适当的时候 使用stopImmediatePropagation来一票否决,实现自己的判断 校验 安全拦截等类似功能。

这是全篇文章的第三部分,这部分内容,我觉得还是比较容易理解的,尤其是前半部分,一般新手朋友,读两三遍,应该能收获不少。事件监听器列表,只要花几个小时,了解一下这个表,对于实际开发中的不少问题,就能心中有数,不知为什么 基本上没有人讲解。

第四篇是事件的循环和异步, 我们下一篇再见。

参考列表:

相关推荐
w2sfot17 小时前
JS代码压缩
前端·javascript·html
码途潇潇17 小时前
从组件点击事件到业务统一入口:一次前端操作链的完整解耦实践
前端
这是个栗子17 小时前
【JS知识点总结】JavaScript 中的精确取整:Math.floor、Math.ceil 与 Math.round
开发语言·javascript·ecmascript
import_random17 小时前
[python]miniconda(安装)
前端
云梦谭17 小时前
AI 生成的FreeSWITCH 呼出流程深度分析freeswitch-1.10.12.-release
java·前端·php
秃了才能变得更强18 小时前
React Native小技巧
前端
一只爱吃糖的小羊18 小时前
React 19 vs Vue 3:深度对比与选型指南
前端·vue.js·react.js
我也想好好学习18 小时前
使用Promise实现串行执行异步任务,含出错重试功能
javascript
前端老宋Running18 小时前
Vue 3 的“降维打击”:Composition API 是如何让 Mixin 成为历史文物的?
前端·javascript·vue.js