Contents

神奇的点击事件

你知道我们在 document 中,用鼠标点击一次页面会发生什么吗?

可能你会告诉我,会触发一个或多个的 click 事件监听器,然后运行这个监听器的回调函数。

但是,这个过程中还有一些奇怪事情的一起出现了…

固定的事件触发顺序

在可注册的 Document Event 中,click 事件自然是最常见的事件。 不过点击一次页面并不是只会触发 click 事件,而是会触发许多个不同但又十分接近事件。 比如也算是老朋友的 mousedownmouseup 等等。

如果说我们在同一个元素上注册多个如: mousedownpointdown 等与点击相关的事件监听器,这时候它们会按照什么顺序触发呢? 触发的时候会是固定顺序的吗?

做一个小小的实验就可以知道,它们的触发顺序是固定的,且与注册顺序无关:

后续所有的 el 与 events 皆为此项不再赘述, 且在做出不同的测试时保证其节点不会存在上一个测试所遗留的事件监听器。(懒得写注销事件代码啦~)

const el = document.getElementById('app');
// 组合事件集合
const events: (keyof HTMLElementEventMap)[] = [
  'click',
  'mousedown',
  'mouseup',
  'pointerdown',
  'pointerup',
  'touchstart',
  'touchend',
];
// 注册组合事件
for (const event of events) {
  el.addEventListener(event, e => {
    console.log('触发了 ->', event);
  });
}

PC 端触发顺序为

pointerdown -> mousedown -> pointerup -> mouseup -> click

http://filebed.xxlsjfx.com/win/2022-11-27-1669556431662.gif

移动端触发顺序为

pointerdown -> touchstart -> pointerup -> touchend -> mousedown -> mouseup -> click

http://filebed.xxlsjfx.com/win/2022-11-27-1669556621616.gif

如上可知,pointerdown 始终是第一触发的事件(比 touchstart 还快)!而在移动端,mousedownmouseup 都是在 touchend 之后触发的。 也就是说,当我们在触摸的时候,mousedown 无法触发,而是在手指离开屏幕的时候才会被触发!

高贵的 click 事件

那如果在始终我们在第一个触发的 pointerdown 事件中阻止了事件的默认行为,会发生什么呢?

再来一次小实验看看:

for (const event of events) {
  el.addEventListener(event, e => {
    if (event === 'pointerdown') {
      e.preventDefault();
    }
    console.log('触发了 ->', event);
  });
}

http://filebed.xxlsjfx.com/win/2022-11-27-1669558212572.gif

这个时候神奇的事情发生了,紧随其后的touchstartpointeruptouchend依旧触发了, 而mousedownmouseup 事件被阻止了, 但是属于最后触发的 click 事件却依然成功的触发了!

这就是 click 的高贵血统吗?

阻塞与异步也无法阻止事件触发的固定顺序

我们知道,浏览器是单线程的,所以在执行一个任务的时候,其他任务都会被阻塞。

那我们触发一个调皮的事件的时候让线程阻塞了,后续事件的触发顺序发生变化吗?

for (const event of events) {
  el.addEventListener(event, e => {
    console.time(`执行事件 ${event} 1`);
    let n = 0;
    // 主线程阻塞130ms左右
    for (let i = 0; i < 1000000; i++) {
      // 浮点数运算
      n += i ** ((Math.PI ** Math.PI) ** Math.PI);
    }
    console.timeEnd(`执行事件 ${event} 1`);
  });
  el.addEventListener(event, e => {
    console.time(`执行事件 ${event} 2`);
    let n = 0;
    // 主线程阻塞130ms左右
    for (let i = 0; i < 1000000; i++) {
      // 浮点数运算
      n += i ** ((Math.PI ** Math.PI) ** Math.PI);
    }
    console.timeEnd(`执行事件 ${event} 2`);
  });
}

http://filebed.xxlsjfx.com/win/2022-11-27-1669559335589.gif

可以看到,事件的触发顺序依旧是固定的,并不会因为事件的阻塞这种小事而产生动摇, 后续的事件需要等待前面的事件执行完毕之后才会被触发。

这看起来显而易见,因为事件的触发也属于主线程的任务,所以当主线程被阻塞的时候,事件的触发也会被阻塞。

那如果我们把事件的执行变成异步函数呢?还是会这么如我们所愿吗?

for (const event of events) {
  el.addEventListener(event, async e => {
    console.time(`执行事件 ${event}`);
    let n = 0;
    // 多了一个 0 所以是 1300ms~
    for (let i = 0; i < 10000000; i++) {
      n += i ** ((Math.PI ** Math.PI) ** Math.PI);
    }
    console.timeEnd(`执行事件 ${event}`);
    console.log('触发了 ->', event);
    await new Promise(resolve => {
      setTimeout(() => {
        console.log('Promise触发了 ->', event);
        resolve(0);
      }, 100);
    });
  });
}

http://filebed.xxlsjfx.com/win/2022-11-27-1669560188571.gif

从 log 中可得出, 参与下一个主线程执行的 Promise 的 log 会在 click 事件之后触发直接一起触发 (也可从中得出 setTimeout 的局限性), 但当前主线程应当触发的事件与其触发的顺序依旧无法被撼动。

事件执行与页面渲染不能不说的关系

我们知道,浏览器的渲染是由主线程来完成的,所以当主线程被阻塞的时候,页面的渲染也会被阻塞。 当在一条事件链中修改了多次的 DOM 时,有关页面实际的绘制也将会被推迟到最后一次修改之后。

// 加一个transition看看效果
el.style.transition = 'all 1s';
for (const event of events) {
  el.addEventListener(event, e => {
    console.time(`执行事件 ${event}`);
    let n = 0;
    // 少了一个 0 所以是 130ms 啦~
    for (let i = 0; i < 1000000; i++) {
      n += i ** ((Math.PI ** Math.PI) ** Math.PI);
    }
    const backgroundColor = el.style.backgroundColor;
    // 简单的切换一下背景色
    el.style.backgroundColor = backgroundColor === 'black' ? 'white' : 'black';
    console.log('触发了 ->', event, el.style.backgroundColor);
    console.timeEnd(`执行事件 ${event}`);
  });
}

http://filebed.xxlsjfx.com/win/2022-11-27-1669561951427.gif

当事件触发完毕之前,el.style.backgroundColor 虽然被修改了,但页面的实际绘制被推迟, 只有其事件所有的执行结束之后,才姗姗来迟的展示出实际切换后的效果。

如果点的快就会导致各种鬼畜的现象:

http://filebed.xxlsjfx.com/win/2022-11-28-1669564910319.gif

渲染因为下一次事件的触发而导致被迫推迟,从而让页面看起像是闪烁了一下, 这样的用户体验是十分糟糕的。


如果说 backgroundColor 的修改只是一个属于 重绘(Repaint) 小事的话, 那么如果我们在事件中修改了会导致更多变化的 回流(Reflow) 类型样式会咋样?

// 加一个transition看看效果
el.style.transition = 'all 1s';
// 加个bg
el.style.backgroundColor = '#eee';
for (const event of events) {
  el.addEventListener(event, e => {
    console.time(`执行事件 ${event}`);
    let n = 0;
    // 少了一个 0 所以是 130ms 啦~
    for (let i = 0; i < 1000000; i++) {
      n += i ** ((Math.PI ** Math.PI) ** Math.PI);
    }
    // 默认100vh
    const height = el.style.height || el.getBoundingClientRect().height;
    // 调整高度
    el.style.height = parseInt(height.toString()) - 300 + 'px';
    console.log('触发了 ->', event);
    // style对象上的
    console.log('style.height ->', el.style.height);
    // 实际渲染的(DOMRect)
    console.log('getBoundingClientRect().height ->', el.getBoundingClientRect().height);
    console.timeEnd(`执行事件 ${event}`);
  });
}

http://filebed.xxlsjfx.com/win/2022-11-28-1669564983267.gif

显然,当事件触发的时候,el.style.height 作为一个对象被修改了并且保存了下来, 但是 el.getBoundingClientRect().height 也就是实际渲染的结果其实并没有被修改。 所以虽然属于回流的绘制阶段,但页面同样也是需要等到事件执行完毕之后才会被重新渲染。

其中的与浏览器绘制相关的奥妙可以看看这篇文章: 浏览器的回流与重绘 (Reflow & Repaint)

总结

在这篇文章中,我们主要讲了一下浏览器的事件触发顺序, 以及主线程中事件触发的阻塞会有什么样的效果。

异步事件并不能阻止用户重复的触发事件, 所以我们应当在发起请求的时候尽量限制用户的操作,避免重复的触发事件。

想要提高用户的体验,我们在事件触发的时候,应当尽量避免阻塞主线程, 并且减少注册相同类型的事件触发器,避免重复的触发事件导致占用过多的执行时间。

限制事件的执行也同样可以用 防抖与节流 来实现。