JS事件深度解析三 事件的传播和处理 多知道一个表 你就进阶了事件绑定 事件监听 事件处理

45 阅读37分钟

三、 事件的传播和处理

简介

我们将介绍事件的传播和处理, 这部分的内容,相关的介绍 文章 帖子 可谓是汗牛充栋栋栋栋栋。。。但是 基本上都是先讲三个阶段 然后扔出几个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> ,浏览器并不是直接运行这段代码。 浏览器引擎在解析 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]]:事件类型(如 &#34;click&#34;, &#34;mousedown&#34;)。
      • [[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来一票否决,实现自己的判断 校验 安全拦截等类似功能。

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

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

本文首发于: 掘金社区

同步发表于: csdn

博客园

码字虽不易 知识脉络的梳理更是不易 ,但是知识的传播更重要,

欢迎转载,请保持全文完整。

谢绝片段摘录。

参考列表:

  • developer.mozilla.org

  • dom.spec.whatwg.org

  • html.spec.whatwg.org

  • tc39.es

  • developer.chrome.com

  • w3.org