Skip to content

事件循环

引言

关于 js 的事件循环和任务队列详细知识在前面的文章已经谈过,本次主要是补充一些新学的知识点关于浏览器的事件循环,由于本次学习的知识点比较零散就按分点来总结。

知识点

  1. setTimeout 在进行一些例如元素移动的时候并不是时间延迟为 0 最好(不用 while 的原因:会阻碍主线程渲染界面,进入一个无法更新界面的状态),因为我们人眼并不会说需要观察那么快,而且浏览器会根据屏幕性能决定最好的刷新时机(就像我们现在在一个代码块连续个一个元素加 style,并不会造成多次更新,而是一次更新完成),所以4.7是最合适的目前来看。

  2. 我们可以把事件循环看成一个帧,所有渲染放在一开始例如样式计算,布局,绘制,然后执行 javascript 的 task。

  3. setTimeout 并不适合用于做动画,因为如果任务执行很慢,会导致帧里面的任务漂移,有的帧一次执行多个任务,而有的帧一个都不执行,产生漂移的动画。

  4. 我们看一下一个代码

    js
    button.addEventListener("click", () => {
      box.style.transform = "translateX(1000px)";
      box.style.transition = "transform 1s ease-in-out";
      box.style.transform = "translateX(400px)";
    });

    我们很明显的可以看出我们的意图是希望一个盒子往右偏移 1000 像素然后再往左偏移 600 像素,然而我们会发现浏览器只会向右偏移 400 像素,这是由于浏览器并不会在意你在 js 写的过程,只会关注最终的结果,就如我们上面所说的写多个 style 并不会多次更新而是一次更新最终结果

  5. 所以我们推荐 requestAnimationFrame(在 css 更新前执行会调),但是我们发现将上面的代码改造成:

    js
    button.addEventListener("click", () => {
      box.style.transform = "translateX(1000px)";
      box.style.transition = "transform 1s ease-in-out";
      requestAnimationFrame(() => {
        box.style.transform = "translateX(400px)";
      });
    });

    仍然还是只会向右偏移 400 像素,这是有由于我们会发现代码是事件是这样:点击按钮 => js 事件 => requestAnimationFrame => 更新界面,所以我们需要再套入一层 requestAnimationFrame,这样就能实现我们的效果或者说使用 getComputedStyle 迫使浏览器更早进行样式计算从而更早进行渲染(可能会让你浏览器在一帧呢做很多多余的工作)。

    js
    button.addEventListener("click", () => {
      box.style.transform = "translateX(1000px)";
      box.style.transition = "transform 1s ease-in-out";
      // 方案1
      getComputedStyle(box).transform;
      box.style.transform = "translateX(400px)";
      // 方案2
      requestAnimationFrame(() =>
        requestAnimationFrame(() => {
          box.style.transform = "translateX(400px)";
        });
      );
    });

    但是值得注意的是 edge 和 safari 的实现可能是错的,他们是将 requestAnimationFrame 放在更新界面之后和回调函数之前执行的,所以用户可能提前看到页面变化,意味直到下一帧才能看到页面变化而且会造成延迟,并且很难批量执行更新任务。

  6. 微任务例如 promise 等异步任务,是在宏任务之后执行的,但不代表他们会屈服于渲染,例如我们做一个死循环function loop() { Promise.resolve().then(loop); } 然后调用 loop 函数,页面也会处于一个无法更新的状态

  7. 宏任务是一次执行一个,Animation callback 和 Micro tasks 队列都是一次性执行完,如果我们像上一点一样微任务添加的速度大于清空的速度,事件循环就会阻塞。

  8. 看一个有趣的代码

    js
    button.addEventListener("click", () => {
      Promise.resolve().then(console.log("Microtask 1"));
      console.log("Listen 1");
    });
    button.addEventListener("click", () => {
      Promise.resolve().then(console.log("Microtask 2"));
      console.log("Listen 2");
    });
    
    // user click the button: Listen 1, Microtask 1, Listen 2, Microtask 2
    
    button.addEventListener("click", () => {
      Promise.resolve().then(console.log("Microtask 1"));
      console.log("Listen 1");
    });
    button.addEventListener("click", () => {
      Promise.resolve().then(console.log("Microtask 2"));
      console.log("Listen 2");
    });
    button.click();
    // javascript click the button: Listen 1, Listen 2, Microtask 1,  Microtask 2

    在一个按钮上绑定两个点击事件,不同情况下输出不一样,这个原因是我们用户点击按钮时,会先调用第一个回调,然后就清空了一个宏任务,然后执行微任务,然后继续下一个宏任务,然后清空,所以会产生这样的结果。而我们如果通过 javascript 点击的时候,button 的 click 函数还没执行完,所以并不算结束了宏任务,我们要继续调用第二个回调,所以就会产生两种不同的输出。简单来说就是处于最外层的宏任务不同,第一种我们的宏任务是两个回调函数,第二种我们的宏任务是button.click()。 所以我们模拟的时候可以使用 promise 来模拟链接的下一次点击

    js
    const nextClick = new Promise((resolve) => {
      link.addEventListener("click", resolve, { once: true });
    });
    nextClick.then((event) => {
      event.preventDefault();
    });
  9. 最后总结一下单击链接如何工作:

    1. 对于用户,我们首先创建了一个事件对象,然后调用每一个监视器,传入事件对象,然后我们检查事件对象的 canceled 属性,如果是就不会打开链接,如果没有 cancel,就打开链接。当调用 event.preventDefault()时,事件会被标记为 canceled,如果用户单击一个链接,那么我的微任务就会在每次回调后发生,因为 Java Script 堆栈清空了。
    2. 但是当我们通过 JavaScript 调用 click 时,它会执行完链接点击的操作,只有在算法完成后才会返回,因此 JavaScript 堆栈永远不会清空,因此在算法完成之前无法调用微任务,即使你有很多 Promise 想要调用 event.preventDefault() 也已经晚了,他会打开超链接然后执行 Promise,记住微任务会因为 JavaScript 堆栈而不同。

参考

Jake Archibald on the web browser event loop

备案号:闽ICP备2024028309号-1