精读Javascript系列(七)事件循环细则 I:微任务、宏任务

本文详细介绍了JavaScript的事件循环机制,包括宏任务与微任务的概念和区别。通过示例,解释了事件循环如何处理任务序列,以及如何影响页面渲染。文章强调了阻塞事件循环可能导致的性能问题,并探讨了解决方案——微任务的使用,如Promise。文章最后更新了事件循环模型,帮助读者更好地理解JavaScript异步执行的底层原理。

前言

对于Javascript异步,我是从其他面向对象编程语言的并发编程层层向下介绍的,在一些细节上并没有多详细说明。此次算是补充所缺,在选择主题时,我茫然了好一阵,决定从微任务和宏任务开始入手,阅读下文时,尽可能有些Promise的基础。

开始吧。

事件循环

首先补充一下上次事件循环的更多细节:

  1. 事件循环开启
  2. 新消息序列设为当前消息序列
  3. 当前消息序列中取出任务
    消息序列是先入先出结构,也就是说它是按照顺序取出的。
  4. 处理任务
    自上而下运行JS代码
    如果发出异步请求,然后将消息保存到这个新消息序列(若无则新建)中
    新消息序列的任务全部被阻塞,等待下次事件循环迭代处理。
  5. 检查当前消息序列是否为空,是则继续,否则转至 (3)
  6. 是否触发UI Rendering事件,是则立即进行视图渲染。 否则继续
  7. 是否新增消息序列
    如果是,开始下一轮事件循环,回到(2)
    否则继续
  8. 确定再无事件,关闭事件循环。线程进入休眠;
    直至有事件发生,新建消息序列并保存消息,转至(1)。

从上面的过程中,可以得到下面的结论:

  1. 此次事件循环将消息序列进行了细分,即当前的新增的,两者并不同。
  2. 一次事件循环,处理一个消息序列,而不只是一个消息。
  3. 一个事件循环都只渲染一次。
  4. 此事件循环仍需改进

宏任务

我们常说的任务(task),都是宏任务(Macrotask),由宏任务组成的消息序列,称作宏任务序列,即 Macrotasks套娃嫌疑确定……),一般都是涉及到 IO操作(包括网络请求、页面渲染等)的任务,例如:

  1. scripts: 脚本代码
  2. Mouse/Key Events : click、onload、input等。
  3. Timers: 定时器,例如setTimeout setInterval等。
  4. 未完待续

注意:Timers工作过程是这样的:

  1. 调用setTimeout时,将消息(回调函数,即task)放到延迟消息队列
  2. 延迟消息队列中的task到期后,放入新Macrotasks中。
  3. 在下次事件循环迭代中等候处理。

示例:
首先搞一个用于生成定时器的函数。

 <div id="text"></div>
 var text = document.querySelector('#text')
   var genTimer = (string,delay=0,cb)=>{
       return ()=>{
           setTimeout(()=>{
           text.innerHTML+=string+'<br/>'
           !cb||cb();
       },delay*1000)
       }
   }    

现在请一直记住脚本代码和Timer是一个宏任务
并且这个函数会一直用到结束为止。
(……想必上面的代码极易理解的吧……)

事件循环与渲染

准备两个消息序列的任务,
算是模拟两次事件循环的消息序列。

 var macroTasks = []
 macroTasks[0] = [genTimer('A1: Be honest rather clever.',1),
                  genTimer('A2: Being on sea, sail; being on land, settle.',1)]
   // 两个队列的到期时间不一致!               
 macroTasks[1] = [genTimer('B1: Failure is the mother of success.',3),
                 genTimer('B2: The shortest answer is doing.', 3)]
 /*
   macroTask[0] 是 下次的消息序列的任务
   macroTask[1] 是 下下次的消息序列的任务
 */

请注意, macroTasks[0]中的task都在1s后到期。因此下轮事件循环中会处理这些tasks。同理,macroTasks[1]中的tasks都在3s后到期,因此会在下下次事件循环中处理这些tasks。

为什么要注意这些区别呢?
因为它们分两次事件循环的处理的!

  text.innerHTML +='start......<br>'
  macroTasks[0].forEach(f=>f())
  macroTasks[1].forEach(f=>f())
  
  text.innerHTML +='end......<br>'

输出如下:
在这里插入图片描述
答案很容易就被猜出来的。但是输出结果并不重要,重要的是现象。
A1A2 以及B1B2仿佛是分成了两次渲染出来的! 这才是关键

说明如下:

  • 一次事件循环,只会处理一个消息序列。由于A1A2Timer是同时到期的,因此会被划分到同一个消息序列中,而一次事件循环只渲染一次,所以A1A2同时被渲染。
  • 同理,B1B2也是同样的情形;但是要注意:B1B2的到期时间与A1A2的并不同,因此它们分为两次事件循环处理的。

基本过程如下:
在这里插入图片描述注意:事实上这次也渲染了,不过这不是我要讲的内容无关,因此略过。

在这里插入图片描述处理完毕后,渲染一次。

在这里插入图片描述最后一次事件循环, 渲染完毕后结束。

综上所述可以知道:

同一个消息序列中的task会共享同一次事件循环,并且会等待所有task处理完成后才会渲染

为什么我们要得到这个结论?
继续吧。

阻塞问题

大多数情况下,我们是不会感知到阻塞的,这一方面是CPU计算能力强悍,另一方面也是JS引擎高性能的原因。

不过偶尔也会出现例外,事实上,我们所说的宏任务基本上都是工作量较大的任务,例如我们的JS代码文件(少说也要有2000行代码吧),如果处理不好,就很容易阻塞(即响应时间超长)。

现在模拟一个阻塞任务,例如:

   var macroTasks =[]
 // 在下次事件循环中,新增一个普通任务,
 macroTasks.push(genTimer('01:Better to light one candle \
                           than to curse the darkness.',0))
// 新增一个超长事件的阻塞任务。
// 阻塞时长3s
 macroTasks.push(genTimer('XXXXXblocking long time!', 
           0,
            ()=>{
               console.log('i am running!');
               let c = Date.now();
               while((Date.now()-c)<=3000);
            }))
 text.innerHTML +='hello<br>'
 macroTasks.forEach(f=>f())
 
 text.innerHTML +='byebye!<br>'
   

注意:macroTasks中所有任务都是同时到期的,因此可知它们会被划分到同一个事件循环中;

然后输出如下(请耐心看下去):
在这里插入图片描述在上面的示例中,尽管将 microtasks中的所有内容都分到了统一个时间循环中,但它们并没有如我们所想的那般在 0s后输出。而是同时阻塞了3s。 这是又为何?

说明:

  • 因为microtask中的所有任务共享一次事件循环,并且只有事件循环的所有任务都处理完毕时才会发生渲染事件
  • 所以可以知道,microtask[0]microtask[1]只有都被处理完成后才能够渲染!但是由于microtask[1]产生了阻塞,最终导致了卡顿

所以可以得到下面的结论:

  • 如果消息序列中有一个task陷入阻塞,那么就会导致整个事件循环陷入阻塞,最终导致卡顿
    事实上,一旦事件循环陷入阻塞,也会影响到下次事件循环的运行。

接下来,当做我们不知道 macrotasks[1] 是阻塞任务。

上面的代码总是Hello之后就ByeBye!!了,内容完全没输出,这是没道理的。所以姑且为了用户体验着想,代码改成这样:

   var macroTasks =[]
   macroTasks.push(genTimer('XXXXXblocking long time!', 
           0.5,
            ()=>{
               console.log('i am running!');
               let c = Date.now();
               while((Date.now()-c)<=3000);
            }))
   macroTasks.push(genTimer('01:Be honest rather clever',1))
   macroTasks.push(genTimer('ByeBye',1))
   text.innerHTML +='hello<br>'
   macroTasks.forEach(f=>f())
 

注意:上面的代码中,macrotasks[0]macrotasks[1]以及macrotasks[2]的事件循环不同,它们已经被错开了。但是仍然被硬生生卡到3s后才输出。原因很简单,因为当前事件循环仍在处理中,所以就推迟了进入下次事件循环的时间

因此总结一条:永远不要阻塞事件循环,它是所有异步模型的黄金铁律。因为它不仅导致严重的卡顿,而且极其影响用户体验,更重要的是:事件循环阻塞就意味着更大的性能开销

因此我们只能在阻塞任务之前处理所有任务,但通常情况下仍不可避免的受其影响,例如阻塞任务的延迟时间为0s时,那么任何宏任务都会受阻塞影响, 惹怒用户第一步,循环阻塞想呕吐

微任务便是上面的一种解决方案(当然最直接的处理办法就把阻塞任务给Pass掉,但是大多数情况下,这种任务偏偏就很重要。)

使用微任务

微任务Microtask简单来说是能够快速完成的任务,并且它保证所有的tasks处理完成后(但仍然在UI Rendering前)进行处理完成。在ES8规范中,微任务用 Job 表示,嘛,不过喜欢 microtask的人更多些,两个术语表达的意思都是相同的。

最经常使用的微任务是Promise

例如下面代码:

    var macroTasks =[]
   macroTasks.push(genTimer('XXXXXblocking long time!', 
           0 ,
            ()=>{
               console.log('i am running!');
               let c = Date.now();
               while((Date.now()-c)<=3000);
            }))
   macroTasks.push(()=>{
       return new Promise(res=>{
           
          text.innerHTML+='lark in the clear air<br/>'
          res('success!');
       })
   })
   macroTasks.push(()=>{
       return new Promise(res=>{
          text.innerHTML+='ByeByeBye!<br/>'
       })
   })
   text.innerHTML +='hello<br>'
   macroTasks.forEach(f=>f()) 
  

输出:
在这里插入图片描述

过程图如下:
在这里插入图片描述
(注意: 上面的macroTasks中混入了两个微任务

虽然现在仍然还是受阻塞影响,但是至少表面上没什么卡顿。当然这只是一种实验;生产环境下无论如何也不要这样做。自此不再赘述。

微任务在浏览器环境下有三个:

  1. queueMicrotask: 微任务回调函数。
  2. Promise: Promise,最常用的
  3. MutationObserver: DOM树监听

这里面除了Promise其他都不怎么常用,有兴趣的可以去了解一下。不过微任务给人的感觉,就像是一个可以追加到宏任务后面的同步代码,微任务定义不重要,重要的是,微任务尽可能是体积较小的任务代码,不要尝试阻塞微任务,否则就失去了微任务的本来含义

将上面的代码再进一步改写:

 var text = document.querySelector('#text')
 function genMicrotask(str){
     return async ()=>{
         text.innerHTML += str + '<br>'
         return str ;
     }
 }
 
 var microTasks = [genMicrotask('01: For man is man and master of his fate.'),
                   genMicrotask('ByeBye!!!')]
     
 text.innerHTML='hello!!!<br>'
 microTasks.forEach(f=>f())

输出:

hello!!!
01: For man is man and master of his fate.
ByeBye!!!

很完美,至少比上次的看起来清爽了许多。
注意:async函数最终也返回一个settled的Promise

好了,微任务和宏任务就先到这里。
(? Promise放后面吧,相信看的人也不是零基础,总知道用法吧……)


更新事件循环模型

根据上面所有内容,加入macroTasksmicroTasks等元素,就是:

  1. 事件循环开启
  2. 新增Macrotasks设为当前Macrotasks
  3. 当前Macrotasks中按顺序取出任务
  4. 处理任务
    自上而下运行JS代码
    如果发出异步请求,然后将消息保存到新Macrotasks(若无则新建)中;
    如果存在Microtasks,那么:
    1. Microtasks中取出任务
    2. 运行代码
    3. 如果发出异步请求,然后将消息保存到新Macrotasks(若无则新建)中
    4. 如果存在Microtask,仍然将其添加到当前macrotask的Microtasks
  5. 检查当前Macrotasks是否为空,是则继续,否则转至 (3)
  6. 是否触发UI Rendering事件,是则立即进行视图渲染。 否则继续
  7. 是否有新增MacroTasks
    如果是,开始下一轮事件循环, 回到(2);否则继续。
  8. 确定再无事件,关闭事件循环。线程进入休眠;
    直至有事件发生,新建消息序列并保存消息,转至(1)。

其实关于事件循环可以简单记作为:

  1. 任何事件循环都是以 Macrotasks --> MicroTasks --> UI Rendering 顺序进行的。
  2. 新增的任何 Microtask 只会保存在 当前事件循环的 Microtasks中。
  3. 新增的任何 Macrotask 只会保存在 新增Macrotasks 中,它是下一轮事件循环的 Macrotasks

最后

原来我心想能带入 NodeJS 的东西, 但是未曾想 NodeJS的底层细节如此复杂,远不是Javascript事件循环模型能概括得了的。对于不明白的原理,小生不敢自以为是,因此只能稍作安排。libuv好难啊。。。

限于篇幅,只能说这么多……但是关于这部分内容涉及知识量极大,有谬误之处,还请慷慨指正,不胜感激。

同步转发:
https://juejin.im/post/5ecfa657f265da76ed484fd9

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值