事件循环和RAF和微任务和执行机制

事件循环和RAF和微任务和执行机制

此文内容基于Jake Archibald的演讲和文章。强力建议观看视频和阅读文章!!!!

视频:深入事件环(In The Loop)Jake Archibald@JSconf 2018

文章:Tasks, microtasks, queues and schedules

事件循环

在视频中作者ppt的图看完后我觉得很有助于理解

事件循环截图
事件循环截图

图中转一圈就是一个事件循环,白色方块(线程)会持续循环,两个开关是浏览器的行为控制。

  • 左侧:任务队列(Tasks)。对应的是文章中的Tasks
    • 当有任务时,方块会走到左侧执行任务。视频中有句话only one task can be processed at a time也就是说一次循环只能执行一个任务。
    • 执行任务时会把任务推入到JS stack中去,如果任务中有微任务(microtask)时会推入到微任务队列中,等JS stack中为空时,就会执行微任务。同样,微任务队列为空时,Tasks中的下个任务才会被推入到JS stack中去。
  • 右侧:页面帧的渲染。
    • 浏览器会决定什么时候进行页面帧的渲染(页面的更新最小周期就是一个页面帧)。首先调用rAf的回调函数,然后后面是css的加载、解析、计算、渲染等。
    • rAF全称是requestAnimationFrame,是一个web接口,会在页面正式渲染前加载。使用这个接口加载页面动画效果能够降低性能损失而且代码会更加规范,后面会有示例演示。

requestAnimationFrame

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行

**下面是比较requestAnimationFramesetTimeout两种方式运行小方块向右移动的动画 **

效果:移动小方块
移动小方块

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    div {
      width: 100px;
      height: 100px;
      background-color: #ccc;
      position: absolute;
    }
    .div-o {
      top: 200px
    }
  </style>
</head>
<body>
  <div class="div"></div>
  <div class="div-o"></div>

  <script>
    var div = document.querySelector('div');
    var div1 = document.getElementsByClassName('div-o')[0];

    function moveDivForwardOnePixel(element) {  
      // 获取元素离窗口左侧的距离
      let left = element.offsetLeft;
      if (left + element.offsetWidth >= document.body.offsetWidth) {
        return false
      }
      element.style.left = 1 + left + 'px';
      
      // console.log(left);
    }

    // raf渲染
    function callback() {
      console.log('raf invoke callback');
      moveDivForwardOnePixel(div)
      requestAnimationFrame(callback)
    }
    callback()

    // setTimeout渲染
    function callback1() {
      console.log('setTimeout invoke callback1');
      moveDivForwardOnePixel(div1);
      setTimeout(callback1, 0); // 虽然设置的延迟时间是0,但是根据标准规定,浏览器会设置任意数字。
    }
    callback1()
  </script>
</body>
</html>

总结:1. 从效果中看, 由setTimeout调用的小方块移动的更快。

  1. 从控制台中可以看出,setTimeout调用方式比requestAnimationFrame调用方式的频率要高出很多,平均setTimeout调用3~4次,raf方式才会调用一次。
  2. 每次循环不一定会渲染页面,从控制台结果可以看出,每3~4次循环才会渲染一次页面(渲染之前要先调用rAF)。函数调用的少了,性能就越好。
  3. 使用setTimeout方式时,每次任务执行是都会在任务队列(Tasks)中再次添加setTimeout,但是一次循环只能处理一个任务,所以每次事件循环都会执行一次。
  4. 使用 raf 时,动画效果会在页面渲染之前调用,保证页面渲染完后会有新的效果,同时这样做之后,合适设置动画效果由浏览器来决定,这样能够节约性能,因为在页面没更新时不管设置多少遍动画,用户都是看不到效果的。

rAF在页面渲染前调用

首先来看视频中截的图,每个区块看作是一个页面帧的周期,紫色和绿色是样式的计算、布局、绘制等(不一定每次渲染都有这些),总是在帧的开头也就是页面渲染的部分,而紫色前就是rAF的调用位置,每次页面渲染都会调用rAF的回调,同时保证每一帧只会调用一次rAF。假设一个帧周期是1000/60毫秒,那么setTimeout(callback,0)在一个帧周期就会调用大于一次。同样的,一个帧周期内也可能会经历多个事件循环,意味着会执行多个宏任务(Task)。

页面帧页面帧

视频例子:作者说需求是元素从1000px位置动画过度到500px位置。

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

结果:元素直接移动到了500px位置。

页面渲染时,只获取到这个click事件(任务)的结果,也就是box.style.transform = 'translateX(500px)';最后一行的结果,因为这个任务被执行的结果就是最后一行。

改进一:

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

结果:元素直接移动到了500px位置。

结合开头的图片,当click事件(任务)被执行时,元素被设置到了1000px处,同时,移动到500px的操作被放到了raf中去了,意味下次页面渲染前会限制性raf中的操作,也就是设置元素到500px处的操作。于是,对于用户来说效果就是元素直接移动到了500px处(因为移动到1000px处时页面没有渲染)

改进二:

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

结果:元素移动到1000px位置然后动画过度到500px位置。按照作者的代码并不能实现🤣

改进二中使用了两层raf,第一次raf中设置的是下一次(不是当前次)页面渲染时移动到500px处,所以效果是经历了两次页面渲染才有的效果。

注意:写demo时发现实现不出视频的效果...是因为transition有1s的过度时间,这1s的时间中第二次rAF被调用了,导致效果就是直接移动到500px位置,如果把设置过渡动画那行注释就能看出元素实际是先移动到1000px位置然后移动到500px的。按照作者的效果, 我想应该是吧transition移动到第二次rAF中去吧。

微任务

宏任务微任务宏任务微任务

我理解的是,Tasks是宏任务,Microtasks是微任务,JS stack是JS调用栈。

  1. 宏任务队列就是事件循环图中左侧的任务队列。

  2. JS调用栈里的执行的是从宏任务队列取出来的。

  3. JS调用栈空了后会执行微任务,微任务空了一个事件循环才算完。

    only one task can be processed at a time

  4. 浏览器监听到的点击事件和js中click()模拟的点击事件在task中生成的任务是不一样的。因此对应的任务执行顺序是会有所不同的,这一点对于自动化测试来说需要注意。

执行顺序

作者视频这样简单解释了一下异步:

Promise callbacks are async fine, but what does async actually mean? I mean all it means is that they happen after synchronously executing code.

Promise的回调是异步的,那么异步到底意味着什么呢?它意味着异步的代码是在同步的代码之后执行的。

Promise.resolve().then(() => console.log('Hey'));
console.log('Yo!');

作者解释针对是这一段代码。Promise的回调会被加入微任务(micro tasks)中,微任务是要等JS stack为空时才会执行,也就是同步代码执行完之后才会执行。

微任务阻塞微任务阻塞

视频中这一段中作者提到,如果微任务在执行的过程中不断有新的微任务加入进来时,那么就会一直执行微任务,会导致事件循环阻塞,继而导致页面无法渲染(卡死)。从开头的图理解就是白色方块一直在处理左侧的任务(这里的任务是Task,仍然是执行了一个宏任务,但是一直在执行微任务),困在了这一次事件循环。

接下来是一个例子,用来展示浏览器的点击事件(task)和脚本的点击方法(task)的区别:

一个按钮有两个点击事件的回调。

情况一:在浏览器中点击btn元素触发点击事件。

// 监听器1
btn.addEventListener('click', () => {
    Promise.resolve().then(() => console.log('microTask1'))
    console.log('Listener 1');
})
// 监听器2
btn.addEventListener('click', () => {
    Promise.resolve().then(() => console.log('microTask2'))
    console.log('Listener 2')
})

此时脚本运行如下:

  1. 触发第一个监听器,监听器1的回调被加载到JS stack中,回调的微任务加载到微任务队列:

    ​ JS stack:监听器1回调

    ​ 微任务:监听器1的Promise回调

    ​ 打印:Listener 1

  2. 第一个监听器的回调完成,此时JS stack为空,开始执行微任务:

    ​ JS stack:空

    ​ 微任务:监听器1的Promise回调

    ​ 打印:Listener 1 microTask1

  3. 触发第二个监听器,监听器2的回调被加载进JS stack中,回调的微任务加载到微任务队列:

    ​ JS stack:监听器2回调

    ​ 微任务:监听器2的Promise回调

    ​ 打印:Listener 1 microTask1 Listener 2

  4. 第二个监听器的回调完成,此时JS stack为空,开始执行微任务:

    ​ JS stack:空

    ​ 微任务:监听器2的Promise回调

    ​ 打印:Listener 1 microTask1 Listener 2 microTask2

情况二:在JS脚本中执行click()方法。

// 监听器1
btn.addEventListener('click', () => {
    Promise.resolve().then(() => console.log('microTask1'))
    console.log('Listener 1');
})
// 监听器2
btn.addEventListener('click', () => {
    Promise.resolve().then(() => console.log('microTask2'))
    console.log('Listener 2')
})
btn.click();	// 脚本触发点击事件

此时脚本运行如下:

  1. 脚本被加入到JS stack。然后脚本触发了第一个监听器,监听器1的回调被加载到JS stack中,回调的微任务加载到微任务队列:

    ​ JS stack:监听器1回调 脚本

    ​ 微任务:监听器1的Promise回调

    ​ 打印:Listener 1

    解释一下,JS stack就是JS的执行栈,栈这个数据结构是先入后出的。因此,下面监听器2的回调会在脚本的前面。

  2. 第一个监听器的回调完成,此时JS stack不为空,并且脚本的click()方法还出发了第二个监听器,于是监听器2的回调被加载到JS stack中,回调中的微任务添加到微任务队列后面:

    ​ JS stack:监听器2回调 脚本

    ​ 微任务:监听器1的Promise回调 监听器2的Promise回调

    ​ 打印:Listener 1 Listener 2

  3. 两个监听器回调完成后,脚本被弹出了JS stack,此时JS stack为空 ,开始执行微任务队列中第一个任务:

    ​ JS stack:空

    ​ 微任务:监听器1的Promise回调 监听器2的Promise回调

    ​ 打印:Listener 1 Listener 2 microTask1

    微任务指的是微任务队列,队列的数据结构是先入先出,因此,先加入微任务队列的微任务会先执行。

  4. 第二个监听器的回调完成,此时JS stack为空,开始执行微任务:

    ​ JS stack:空

    ​ 微任务:监听器2的Promise回调

    ​ 打印:Listener 1 Listener 2 microTask1 microTask2

两种情况的总结:

  1. 触发事件的对象不一样,JS stack中的情况会不一样。
  2. 微任务执行依据是JS stack是否为空。
  3. 这就是作者说的用js脚本执行自动化测试时需要注意的地方。

最后

最后作者举了个小例子解释执行顺序对代码的影响(我觉得说很极端的情况hiahiahia😂)

场景:假设点击超链接,在Promise的回调中取消超链接的跳转。

const nextClick = new Promise(resolve => {
	link.addEventListener('click', resolve, { once: true})
});

nextClick.then(event => {
	event.preventDefault();
});
link.click();

因为Promise的then方法才是异步,所以点击事件发生后,link.addEventListener('click', resolve, { once: true})监听器会立刻触发,同时resolve会即可完成。如果事件是浏览器事件,此时JS stack中为空,随后微任务执行阻止了跳转动作。而如果事件是脚本的click()方法,此时脚本会一直存在在JS stack中,直到跳转完成后,JS stack才会为空,而此时执行微任务阻止跳转已经是不可能的了。

链接的点击过程链接的点击过程

链接点击事件的步骤:

  1. 先创建点击事件对象。

  2. 遍历所有的“点击”的监听器。

  3. 如果事件对象canceled flag没有设置,就会跳转到链接位置。

    event.preventDefault()方法就是在事件对象中设置canceled flag来阻止跳转的。click()方法调用时会执行算法,算法没结束是不会执行微任务的。

(完)


我的废话还是很多的。

推荐阅读:这一次,彻底弄懂 JavaScript 执行机制(语言诙谐,如果里面示例自己做出来和答案一样,就说明真正理解了)

posted @ 2021-06-16 15:10  Aienming  阅读(900)  评论(0)    收藏  举报