一道题带你一命无伤,快速通关JS事件流

319 阅读6分钟

前言:

话不多说先看题:

document.body.addEventListener('click', () => {
      setTimeout(() => {
        console.log(1);
      })
      console.log(2);
    }, false);
    document.body.addEventListener('click', () => {
      setTimeout(() => {
        console.log(3);
      })
      Promise.resolve().then(() => console.log(4))
      console.log(5);
    }, true);

接下让让我们由浅入深,一步步解析本题。

事件流:

DOM操作中存在诸多事件,这些事件的发生过程就称为事件流。

它描述了事件从触发的源头开始,在 DOM(文档对象模型)树中传播的过程。当一个事件在页面中的某个元素上被触发时,比如用户点击了一个按钮或者按下了一个键盘按键,这个事件不会仅仅局限于被触发的元素本身,而是会按照一定的顺序在 DOM 树中流动,这个流动的过程就是事件流。

事件流的三个阶段:

事件冒泡: 事件开始时有具体的元素接收,然后逐级向上传播到DOM最顶层结点过程

目标阶段: 到达的具体元素

事件捕获: 由DOM最顶层节点开始,然后逐级向下传播到最具体的元素接收的过程

如下图所示:

image.png

事件流执行流程:

如下代码所示我创建了三个盒子,并将其分别进行嵌套,并在每个盒子的身上注册了点击事件,打印盒子代表的字母。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    #a {
      width: 400px;
      height: 400px;
      background-color: rgb(245, 56, 13);
    }

    #b {
      width: 200px;
      height: 200px;
      background-color: rgb(27, 140, 238);
    }

    #c {
      width: 100px;
      height: 100px;
      background: pink;
    }
  </style>
</head>

<body>
  <div id="a">a
    <div id="b">b
      <div id="c">
        c
      </div>
    </div>
  </div>

  <script>
    let a = document.getElementById('a')
    let b = document.getElementById('b')
    let c = document.getElementById('c')

    a.addEventListener('click', () => {
      console.log('a被点击');
    })

    b.addEventListener('click', () => {
      console.log('b被点击');
    },)

    c.addEventListener('click', () => {
      console.log('c被点击');
    })
    
  </script>
</body>

</html>

image.png

image.png

我们会发现点击了c元素,使得三个点击事件都被触发了。这是由于三个点击事件都在冒泡阶段触发,addEventListener方法是能够接受三个参数的,分别为事件类型、回调函数、冒泡(false)or捕获(true)。当我们没有对addEventListener传递第三个参数时,默认是false,也就是事件默认在冒泡阶段触发。

让我们再次回到鼠标点击时的那一刻到底发生了什么?由于鼠标点击,浏览器检测到鼠标点击后,此时会触发addEventListener这个异步操作。事件流开始出现(一次点击只会出现一次事件流),由于我们的鼠标是点击到了最里层的c盒子。由上图的事件流图可知目标阶段就是停留在了c盒子上,此时开始从c盒子沿着父盒子一次冒泡。又因为在三个监听函数里,默认都是在冒泡阶段触发打印,所以会从c盒子开始沿着父盒子开始打印。

捕获阶段

当我们将第三个参数填入true时,会将该事件改造为在捕获阶段才执行

a.addEventListener('click', () => {
      console.log('a被点击', true);
    })

    b.addEventListener('click', () => {
      console.log('b被点击');
    },)

    c.addEventListener('click', () => {
      console.log('c被点击');
    })

image.png

当我们依旧点击c盒子时,此时打印结果是首先打印了a盒子,同样的由上图的事件流过程可知,首先进入捕获阶段,此时a盒子已经被捕获到了,因此是第一个被打印的。

阻止冒泡:

event.stopPropagation()
a.addEventListener('click', () => {
      console.log('a被点击');
    }, true)

    b.addEventListener('click', () => {
      console.log('b被点击');
    },)

    c.addEventListener('click', () => {
      console.log('c被点击');
    })

image.png

如上图所示,event.stopPropagation()是阻止事件冒泡的一种方式,当程序进入冒泡阶段并冒泡到了b盒子处时,将b盒子的打印执行完毕,会直接掐断事件流,即事件流冒泡到b盒子处时就结束了,所以不会冒泡到a盒子处,也不会执行a盒子的监听事件中的打印语句了。

b.addEventListener('click', () => {
      event.stopPropagation()
      console.log('b被点击');
    },)

值得一提的是,在我们将event.stopPropagation()写在了打印语句之前,仍然会将打印b执行,由此可见event.stopPropagation()是会等待该监听盒子中的回调函数执行完毕后才会阻止冒泡,即只会阻断a盒子的冒泡而不会阻断b盒子自己的冒泡。

event.stopImmediatePropagation()

这个方法也用于阻止事件传播,但它不仅会停止事件继续传播,还会阻止同一元素上其他事件处理程序的执行。

即使同一个元素上有多个事件处理程序,只要其中一个调用了 stopImmediatePropagation(),那么其他事件处理程序都不会被执行。

题目解析

宏任务

addEventListener 属于I/O 是属于宏任务,每一个监听事件就相当于一次事件循环(event loop)。

话不多说直接上代码

    document.body.addEventListener('click', () => {
      console.log(1);
    });
    document.body.addEventListener('click', () => {
      console.log(2);
    });

image.png

通过点击了一次网页,毫无疑问地按照顺序打印了1和2。(此时只会触发一次事件流)

我们再从event loop的视角出发看待这段代码,事件是相当于一个宏任务,两个事件就是两个宏任务。此两个宏任务按照代码片段的顺序进入宏任务的队列当中,队列是遵循先进先出的原则,因此第一个进入的宏任务就会先一步执行,也就是打印1会先执行,然后将第二个宏任务执行,打印2,此时程序执行完毕。

微任务

我们都知道promise对象.then方法是一个经典的微任务,那如果我们将微任务加入会怎么样呢? 代码如下:

    document.body.addEventListener('click', () => {
      Promise.resolve().then(() => console.log(1))
      console.log(2);
    });
    document.body.addEventListener('click', () => {
      Promise.resolve().then(() => console.log(3))
      console.log(4);
    });

image.png

我们还是从event loop视角出发,第一个监听事件是一个宏任务,此时在宏任务里面的console.log(2);是属于同步代码,而Promise.resolve().then(() => console.log(1))是一串异步代码,同步代码先执行,异步代码后执行。让我们再深入一点,Promise.resolve().then(() => console.log(1))异步代码中的微任务,会将微任务加入到微任务的队列当中,因此执行顺序是第一个宏任务执行同步代码,检测微任务队列,有没有执行代码,将微任务队列代码执行,检测宏任务队列进行循环。

事件流的结合

    document.body.addEventListener('click', () => {
      Promise.resolve().then(() => console.log(1))
      console.log(2);
    }, false);
    document.body.addEventListener('click', () => {
      Promise.resolve().then(() => console.log(3))
      console.log(4);
    }, true);

image.png

如代码段所示,我们将第二个监听事件改造成了捕获阶段。我们再次回到点击时,首先接受到点击,触发事件流,在捕获阶段已经捕获到了第二个点击事件是一个宏任务,此时宏任务入队列,捕获阶段和目标阶段结束,进入冒泡阶段,第一个点击事件的宏任务入队列。在此时第一个点击事件已经排在后面,因此才会打印 4 3 2 1

宏任务的嵌套

回到文章开头的那道题,相信大家应该已经能够看懂了

    document.body.addEventListener('click', () => {
      setTimeout(() => {
        console.log(1);
      })
      console.log(2);
    }, false);
    document.body.addEventListener('click', () => {
      setTimeout(() => {
        console.log(3);
      })
      Promise.resolve().then(() => console.log(4))
      console.log(5);
    }, true);

image.png

首先,捕获阶段使得第二个点击事件先触发,宏任务中的同步代码先执行,微任务代码接着执行,setTimeout明显是一个宏任务重新进入宏任务队列等待前面的宏任务执行完成,接着第二个宏任务(第一个点击事件)同样同步代码->微任务->宏任务入队列,接着第三个宏任务->第四个宏任务->代码执行完成。