深刻Event Loop

舒适提示:如无特殊交代,本文所给出的示例代码的执行环境均为浏览器chrome v81.0.4044.92。javascript

前言

在这个言必称single threaded,event loop,microtask,macrotask......的javascript时代,相对深刻地去了解这些概念和概念背后实现的运行机制是十分有必要的。html

2014年的时候,Philip Roberts前后在Scotlan JS大会和JSConfEU大会发表了关于event loop的优秀演讲,轰动业界(详见个人演讲整理)。这同时意味着event loop这个概念正式地进入到前端开发者的视野(于此同时,国内业界发生了著名的打脸事件,详见JavaScript 运行机制详解:再谈Event Loop)。前端

随着mutiple-processor计算机普及和前端做业愈来愈繁杂,javascript的并发编程愈来愈被重视。而javascript的并发模型是基于event loop机制的。因此,理解好event loop的实现机制可以帮助咱们在并发编程的大背景下,更好地优化和架构咱们的代码。java

理由如此充分,那咱们还在等什么?node

正文

术语

须要反复强调的是,概念是人类有效沟通交流的基础,更确切地说,将同一个(概念)“名”理解为同一个“实”,即概念理解的一致性是人类有效沟通交流的基础。概念落实到某个相关领域就称之为“术语”。鉴于不管是官方文档仍是业内技术文章在使用术语的不一致性,咱们有必要梳理一下阐述event loop过程当中所涉及的术语,以下:react

task

在MDN的诸多阐述event loop相关的文档中,都使用了这个术语。在这篇文档中,task的定义是这么下的:git

A task is any JavaScript code which is scheduled to be run by the standard mechanisms such as initially starting to run a program, an event callback being run, or an interval or timeout being fired.github

这篇文档中说到:web

The tasks form a queue, so-called “macrotask queue” (v8 term)面试

能够看到,咱们每天一口一个的macrotask,好比:setTimeout/setInterval等等的callback就是一个“task”。

task queue

task会被推入到一个队列当中,等待调度。这个队列就是task queue。在Philip Roberts的演讲中,他提到了一个叫“callback queue”的术语,同时他也提到了,它就是“task queue”的别名。

macrotask

macrotask === task,这里就不赘述了。

macrotask queue

macrotask queue === task queue === callback queue,这里也不赘述了。

messsage

这篇MDN文档,通篇下来都在用“message”这个术语,能够看得出,这个message跟它对应的callback一块儿,二者能够统一称之为“task”。那么里面所说的“message queue”就是“task queue”。

microtask

MDN上如是说:

A microtask is a short function which is executed after the function or program which created it exits and only if the JavaScript execution stack is empty, but before returning control to the event loop being used by the user agent to drive the script's execution environment.

简单理解就是,microtask是一个“小”函数(正好呼应了它的名字:micro)。这个函数仅在JavaScript执行栈为空,而且建立它的函数或程序退出后才会执行。可是,它会在将控制权返回给用户代理以前执行。“控制权返回给用户代理以前”是什么意思呢,其实就是“下一个event loop以前”的意思。

microtask queue

跟macrotask queue同样,microtask queue也是用于存放microtask的队列。

job

在jake archibald的这篇技术文章中,他指出了“job”的概念来自于ECMAScript规范,它与“microtask”几乎等同,可是不彻底等同。业界对二者区别的阐述一直处于含糊不清的状态。鉴于整篇文章下来,在他的阐述中,他已经把“job”等同于“microtask”了。因此,我也倾向于采用这种理解。

而另一位同行Daniel Chang 在他的技术文章Microtasks & Macrotasks — More On The Event Loop也秉持着一样的见解:

In this write up I’ve been using the term task interchangeably between macrotask and microtask, much of the documentation out there refers to macrotasks as tasks and microtasks as job. Knowing this will make understanding documentation easier.

call stack/execution context stack

首先,咱们先去维基百科上面看看有关于call stack的定义。

在计算机科学中,call stack是一种存储计算机程序当前正在执行的子程序(subroutine)信息的栈结构......使用call stack的主要缘由是保存一个用于追踪【当前子程序执行完毕后,程序控制权应该归还给谁】的指针.....一个call stack是由多个stack frame组成。每一个stack frame对应于一个子程序调用。做为stack frame,这个子程序此时应该尚未被return语句所终结。举个例子,咱们有一个叫DrawLine的子程序正在运行。这个子程序又被另一个叫作DrawSquare的子程序所调用,那么call stack中顶部的布局应该长成下面那样:

结合维基百科给出的定义和Philip Roberts的演讲,咱们再来看看MDN的诸多文档通篇下来使用的“execution context stack”这个概念。顾名思义,“execution context stack”固然是由“execution context”组成的,那“execution context”又是什么?不难理解,“execution context”就是维基百科给出示意图中stack frame里面的“Locals of xxx”,即函数执行所须要用到的上下文环境。

最后,咱们能够看出,“execution context stack”其实就是“call stack”的子集。由于在event loop语境下,咱们不关心stack frame里面的其余“成分”:“parametres for xxx”和“return address”。因此,二者能够等同起来理解。

小结

有鉴于业内技术文章对这些术语的使用频率,在本文的阐述中,相比于“task”,我会采用“macrotask”的叫法;相比于“job”,我会采用“microtask”的叫法;相比于“execution context stack”,我会采用“call stack”的叫法。

That is a deal!

什么是event loop?

从广义上来讲,event loop就是一种【user agents用于协调各类事件,用户交互,脚本执行,网络活动等执行时机的】调度机制。

实现event loop的user agent有好多个。好比说:

  • window

  • worker

    worker又能够分为:

    • dedicated worker
    • shared worker
    • service worker
  • worklet

  • nodejs

实现event loop的通用算法(注意,这是简单的,通用的算法)大概是这样的:

  1. 时刻监视task queue, 当task queue不为空的时候:
    • 执行队列中入队时间最久的那个task
  2. 休眠。直到等到有可执行的task出现,跳回1。

用代码来简单表示就是:

while (queue.waitForTask()) {
  queue.processNextTask()
}
复制代码

做为单线程的javascript就是经过event loop来实现了它的异步编程特性。

鉴于实现event loop的user agent之多和时间有限,我在这里只是深刻讨论浏览器中的event loop(特指window event loop)和nodejs中的event loop。

浏览器中的event loop

我这里为了通俗易懂,使用了“浏览器中的event loop”这种描述方式。在后面,如何特殊交代,它都是特指规范文档里面的“window event loop”。

上面指出,咱们将会统一使用“call stack”,“microtask”和“macrotask”等术语来阐述event loop。

macrotask跟microtask的种类

在浏览器这个上下文中,macrotask有如下的种类:

  • 当外部或内部<script>标签所对应的脚本加载完成以后,执行这些脚本就是一个macrotask;
  • 当用户点击页面上的按钮,那么分发click事件后的对handler的执行就是一个macrotask;
  • 调用setTimeout或者setInterval时传入的callback的执行,就是一个macrotask;
  • 非标准全局方法setImmediate()调用时传入的callback的执行,就是一个macrotask;
  • requestAnimationFrame调用时传入的callback的执行,就是一个macrotask;
  • .......

microtask的种类有如下几种:

  • 在promise对象调用then/catch/finally的时候传入的callback的执行,就是一个microtask;
  • 显式地调用queueMicrotask(fn)来入队一个microtask时候,那么对这个fn函数的执行就是一个microtask;
  • new MutationObserver()传入callback的执行就是一个microtask;
  • Object.observe()传入callback的执行就是一个microtask。

处理模型图

以Chrome浏览器为例,event loop的处理模型图大概是下面这样:

上图中,须要事先交代两点:

  • (1),(2),(3)表示的是“一次loop中,不一样类型队列被检查是否为空的顺序;
  • render callback queue可能没法对应到具体的实现,可是从心智模型的角度来讲,引入它,有助于推演event loop。

下面咱们解释一下这个处理模型图。

  1. 当浏览器遇到一个有代码的<script>标签的时候,那么浏览器就进入了一个以(window)event loop为驱动的执行流中。外部<script>与内部<script>不一样的一点是多了网络加载过程,无论怎样,咱们的执行流从代码可执行的那一刻开始讲起。首先,浏览器会把整段脚本的执行看成像C语言里面的main函数去执行。这个时候,“main”函数推入到call stack,成为了第一个call frame。“main”函数的执行就是从上到到下,遇到同步代码的调用,就往call stack增长一个call frame,遇到异步代码,就把它交给web API来处理。一方面,同步代码调用完成后(好比说遇到return语句),它所对应的call frame就会从call stack中pop走,以此类推,直到call stack为空,程序就把控制权交回给event loop。另外一方面,到了相关的时间点,web API就会把一个callback封装成一个task,把它推入到它所属的队列中。好比,从上到下执行“main”函数的时候,遇到了如下代码:
setTimeout(()=> {
    console.log('time out');
});
复制代码

那么浏览器就会把这个setTimeout调用交给web API,而后把它从call stack中pop出来。web API接收到个setTimeout调用后,它会在本身的线程里面启动一个定时器,由于在这段代码里面,没有传递time out时间,那么就是默认的0。接着,web API没有丝毫犹豫,它就把setTimeout的callback推入到它所属的macrotask queue里面。假如是遇到在promise对象身上调用then/catch/finally方法,那么它们的callback最终会被web API推入到microtask queue中;假如遇到的是界面更新的DOM操做,那么这些DOM操做就会被封装成一个render callback,推入到render callback queue中。这些callback通过封装后成为一个task,静静地躺在各自的队列中,等待调度。等待谁的调度呢?固然是等待event loop的调度。

  1. event loop负责监视call stack,一旦call stack处于清空状态,那么它首先会去看看microtask queue是否有task。有的话,它就取出队列中入队时间最长的task,

注意,从标准规范的角度来看,“取出队列中入队时间最长的task”这种表述是不正确的,详见规范文档。可是至于在V8的内部,具体实现是如何的呢,目前就不得而知了。为了易于理解和帮助推演,本文姑且采用这种表述方式。

而后把它推入到call stack去执行。跟macrotask queue和render callback queue不一样的,一旦第一个microtask在call stack执行完以后,第二个microtask就会紧跟着推入到call stack去执行,而不是等到下一次的event loop才会执行。也就是说,microtask queue中的全部 micrtask会被一次性执行完毕。

  1. 当call stack再次为空的时候,这时候就轮到render callback queue了。render callback主要是处理UI渲染相关的事务。当call stack为空后,浏览器就会去render callback推入大call stack中,UI渲染完成后,render callback也就从call stack弹走,call stack再次为空。这样子,一个loop就结束了。当一段新的代码片断须要执行或者某个UI事件触发了,那么浏览器就会进入下一个loop。

以上是理论表述,下面咱们结合一下实际的代码来验证并理解上面的话。

<script>
    console.log(1);
    
    setTimeout(()=>{
        console.log(2);
    }, 0);
    
    new Promise(res=> {
        res();
    }).then(()=> {
        console.log(3);
        throw new Error('error');
    }).catch(e=> {
        console.log(4);
    }).finally(()=> {
        console.log(5);
    });
    
    console.log(6);

</script>
复制代码

就像上面所说的那样,对<script>标签所包裹的代码的执行是一个macrotask。为了方面描述,咱们能够把这段代码的初始执行理解为C语言中“main函数”。event loop首先执行macrotask,因此,“main()”调用推入到call stack中。这个时候,遇到同步代码的console.log(),那么就推入call stack,在浏览器控制台打印1以后,console.log()就会被pop出call stack。接着下来,call stack会执行setTimeout(),call stack立刻把它交给web API,而后把它从call stack中弹走。由于web API的实现并不在js engine(特指V8)里面,而是另一个线程里面,因此js engine的执行跟web API的执行能够是并行的。在call stack继续往下执行的同时,web API会检查setTimeout调用时传入的表示须要延迟的time out,发现它为默认的0,因而乎就立刻把相应的callback推入到macrotask中。与此同时,call stack执行到了new Promise().then().catch().finally()语句。值得注意的是,这段语句回分两步执行:

(1)const temp = new Promise(executor);

(2)temp.then().catch().finally();
复制代码

第一句promise实例的构造调用是属于同步代码,会在call stack中执行。对构造好promise实例的then/catch/finally方法的调用,都会交给web API,web API会在promise被reslove的时候,把这些方法所对应的callback推入的microtask queue中。由于在这里,promise会被立刻reslove掉,于是then/catch/finally这三个方法的callback所以会立刻被推入到microtask queue中。

到了这里,若是咱们只关注macrotask queue和microtask queue的话,而且为了描述简单起见,咱们用须要打印的数字来表明相应的task,那么两个队列的应该是长下面这个样子:

macrotask queue:
---------------------------
|   2    |                 |
---------------------------

microtask queue;
 ---------------------------
|   3    |    4    |   5   |
---------------------------
复制代码

好,咱们继续往下看,如今call stack依然有着“main()”占据着,并不处于清空状态。由于咱们还要一句“console.log(6)”没执行。好吧,执行它,在浏览器打印出“6”,而后把它从call stack中pop走。到了这里,咱们已经到达了“main”函数的底部,“main”函数调用完毕,因而它也从call stack中pop走。此时call stack终于处于清空状态了。

好了,一直处于欲睡未睡状态的event loop看到call stack为空,它立刻就打起十二分精神了。由于“main”函数调用自己就是一个macrotask。轮完macrotask,那么此次得轮到microtask queue了。一人一次,至关的公平,是吧?就想咱们上面所说的,microtask queue中全部的microtask是会被依次被推入到call stack,整个队列会被一次性执行完并清空的。因此,浏览器控制台会依次打印“3”,“4”和“5”。打印完毕后,call stack从新回到清空状态。这一次, 轮到render callback queue了。由于咱们这段代码中并无操做界面的东西,因此render callback queue是空的。event loop看到这个队列中为空,心中大喜,想着这一次的event loop结束后,本身终于能够休息了。可是可怜的event loop是劳碌命,它被浏览器逼迫着进入了下一个loop中去了。

在下一个loop中,老规矩,咱们仍是会先检查macrotask queue。这个时候,它发现有一个macrotask在里面,因而它二话不说,把它推入到call stack去执行,最终在浏览器控制台打印出“2”,call stack处于清空状态。event loop接着看microtask queue和render callback queueu,发现这个两个队列都是为空。最终的最终,event loop能够歇着了,它如愿以偿地进入休眠状态。

为了验证一下咱们的理解是否准确,咱们不妨把代码复制到chrome浏览器控制台去运行一下,结果是这样的:

能够看出,实际运行的结果跟咱们推演的结果是一致的,咱们的理解应该是没错的。

macrotask跟microtask的不一样点

macrotask和microtask虽然都会被入队到队列中,都会最终能推入到call stack去执行,可是二者的不一样的仍是挺明显的,而且对于理解整个event loop的运行机制仍是挺重要的。它们两个之间主要有两个不一样点:

  • 在同一个event loop中,执行次序不一样。一个event loop一旦开始了,老是先执行macrotask,后执行microtask。
  • 同一个队列中,相邻任务的相对执行时机不一样。对于macrotask queue而言,相邻的任务会分散不一样批次的event loop去执行;而对于 microtask queue而言,相邻的任务会在同一批次的event loop执行完,而且是连续性地,依次地执行完。

下面,咱们在chrome浏览器(v81.0.4044.92)跑几个例子来验证一下。

先说一下第一个不一样点。不管是script标签内部的js代码片断仍是经过外部加载进来的js代码文件,浏览器都会将对它的执行视为一个macrotask,这也是驱动js代码执行流的第一个macrotask。从这个角度来看,microtask老是从macrotask衍生而来的,那咱们凭什么能说“在同一个event loop中,microtask会比macrotask先执行呢?”。这道理就好像,妈妈把儿子生下来以后,儿子长大后,指着妈妈说:“我长得比你高,我比你先来到这个世界”。你不以为不符合逻辑吗?不过,话说回来,要想经过浏览器控制台的打印顺序来正面证实 macrotask比microtask先执行仍是挺难的。不过咱们能够反向证实一下。

假设js引擎扫描代码后并无把整个js代码片断/文件做为macrotask来执行,而是把“console.log(1)”和promise的代码分别入队到macrotask queue和microtask中。当js引擎准备执行代码的时候,倘若它是先执行microtask,后执行macrotask的话,那么,控制台会先打印“2”,后打印“1”。实际上,这段代码不管你执行多少次,结果都是同样的:会先打印“1”,后打印“2”。这就反向证实了两点:1)代码片断和代码文件的执行自己就是一个macrotask;2)从源头上说,microtask是做为macrotask的一个执行结果而存在的,或者说,macrotask衍生了microtask。 因此,从表象上说,确定是先执行macrotask,再执行microtask。

这里再次强调,第一点理解“代码片断和代码文件的执行自己就是一个macrotask”是十分重要的。由于一旦你看不到它的话,那么你就会下错结论。请看下面这个简单图示:

基于event loop的执行流:
======================================================================
|| macrotask || microtask || macrotask || microtask || .....
======================================================================
^            ^
|            | 
|            | 
|            | 
观察点1      观察点2
复制代码

由于macrtask queue和microtask queue是交替式地获得一次推入call stack的机会的。那么,如图,若是你忽略了“代码片断和代码文件的执行自己就是一个macrotask,而且是驱动执行流的第一个macrotask”这个实现上的事实后,光从控制台的打印结果去作简单判断的话的话,那么实际上你是站在了“观察点2”上。这个时候,你会以为先执行microtask,后执行macrotask的。然而,这并非事实。

综上所说,macrotask是先于microtask先执行的。

第二个不一样点,却是能够经过简单地在浏览器控制台运行代码来验证。

首先,咱们先来验证一下,同一个event loop中,microtask是批量地,依次地执行的,而macrotask是单个执行的

setTimeout(()=>{
    console.log(2);
}, 0);

setTimeout(()=>{
    console.log(3);
}, 0);

Promise.resolve().then(()=> {
    console.log(4);
});

Promise.resolve().then(()=> {
    console.log(5);
});
复制代码

初始macrotask执行后,macrotask queue和microtask queue应该是长这样的(跟上面阐述同样,一样是用【所须要打印的数字】来标志这个任务):

---------------------------
|   2    |    3   |
---------------------------

microtask queue;
 --------------------------
|   4   |    5    |  
---------------------------
复制代码

若是,单个macrotask跟单个microtask是交替执行的话,那么打印结果将会是:

4
2
5
3
复制代码

可是实际上打印结果是:

看这个结果,你可能会说,我是看到microtask是批量执行了,可是macrotask不也是“批量执行”吗?。实际上,不是这样的。那是由于进入第二次event loop以后,执行完(2)以后,microtask queue中并无任务的任务可执行,因而乎又进入了第三次event loop,这个时候,才执行了(3)。下面咱们在第一个setTimeout的callback入队一个microtask(为了简便起见,这里用全局方法queueMicrotask)来试试看:

setTimeout(()=>{
    console.log(2);
    queueMicrotask(()=> {
        console.log(2.5);
    });
}, 0);

setTimeout(()=>{
    console.log(3);
}, 0);

Promise.resolve().then(()=> {
    console.log(4);
});

Promise.resolve().then(()=> {
    console.log(5);
});
复制代码

若是macrotask也是批量执行的话,那么打印结果将会是:

4
5
2
3
2.5
复制代码

可是实际打印结果是什么呢?实际以下:

实际的打印结果是:

4
5
2
2.5
3
复制代码

这是为何呢?这是由于,浏览器在走完第二次的event loop的macrotask以后,代码使用queueMicrotask入队了一个microtask(2.5)。上面说过,一旦执行完一个macrotask,接下来就会去检查microtask queue是否有任务等待执行。此时,正好有一个microtask(2.5)在里面,因此,event loop就把它推入到call stack执行了,而后打印出“2.5”。再而后才进入第三次的event loop,这才有了macrotask(3)的执行。

上面,基本上是在验证microtask执行的“批量性,依次性”。那下面来验证,microtask执行的“连续性”。简单来讲,若是一个microtask在call stack上执行的过程当中致使了一个新的microtask入队,而这个新的microtask在call stack执行过程当中又致使了一个更新的microtask入队,如此类推.....的话,那么这些连续产生的microtask都会在同一次event loop中被连续地执行完,中间不会去执行macrotask queue或者render callback queue里面的任务。注意,这里强调的是“致使了一个新的microtask入队”的意思是指,浏览器以几乎能够忽略的时间差,真正地把一个microtask入队到microtask queue中。好比,下面的代码就不是“致使了一个新的microtask入队”:

Promise.resolve().then(()=> {
    setTimeout(function macrotask2() {
        queueMicrotask(()=> {
            console.log(4.5);
        });    
    }, 0)
});
复制代码

由于只有当“macrotask2”这个macrotask被推入到call stack去执行的时候,(4.5)这个microtask才会真正入队。

去掉setTimeout的包裹,才是真正的“致使了一个新的microtask入队”:

Promise.resolve().then(()=> {
   queueMicrotask(()=> {
        console.log(4.5);
    });  
});
复制代码

以上这两种状况对执行流有啥影响呢?咱们下面看看各类的打印结果的差别。

(1)有setTimeout这层wrapper:

setTimeout(()=>{
    console.log(2);
    queueMicrotask(()=> {
        console.log(2.5);
    });
}, 0);

setTimeout(()=>{
    console.log(3);
}, 0);

Promise.resolve().then(()=> {
    console.log(4);
    setTimeout(()=> {
        queueMicrotask(()=> {
            console.log(4.5);
        });    
    }, 0)
});

Promise.resolve().then(()=> {
    console.log(5);
});

// 打印结果是:
4
5
2
2.5
3
4.5
复制代码

(2)把setTimeout这层wrapper去掉后:

setTimeout(()=>{
    console.log(2);
    queueMicrotask(()=> {
        console.log(2.5);
    });
}, 0);

setTimeout(()=>{
    console.log(3);
}, 0);

Promise.resolve().then(()=> {
    console.log(4);
    queueMicrotask(()=> {
        console.log(4.5);
    });    
});

Promise.resolve().then(()=> {
    console.log(5);
});

// 打印结果是:
4
5
4.5
2
2.5
3
复制代码

从打印结果来看,你能够看到二者执行流的差异吗?一个是4.5放在最后打印了,一个是接着前面两个的microtask的尾巴,连续打印了。说了这么多,我就是想说,我这里所说的“microtask执行的连续性”是指第二种状况。下面咱们把这个例子放大来看:

setTimeout(()=>{
    console.log(1);
}, 0);

Promise.resolve().then(()=> {
    console.log(2);
    queueMicrotask(()=> {
        console.log(3);
        queueMicrotask(()=> {
            console.log(4);
            queueMicrotask(()=> {
                console.log(5);
                queueMicrotask(()=> {
                    console.log(6);
                });
            });
        });
    });    
});
复制代码

你猜猜打印结果如何?

你猜对了吗?到这里,不知道你看清楚所谓的“microtask执行的连续性”是啥没?它的具象化理解其实就是“连续入队的microtask会被依次,连续地推入到call stack去执行,中间不会调度其余的任务(macrotask后者render callback)去打断这种连续性”。

实际上microtask这种连续性,在使用不当(好比说入队过多,递纳入队)的时候,就会长期占用call stack,本质上形成了浏览器运行的阻塞。MDN在文档里面也给出了相关的警告:

Warning: Since microtasks can themselves enqueue more microtasks, and the event loop continues processing microtasks until the queue is empty, there's a real risk of getting the event loop endlessly processing microtasks. Be cautious with how you go about recursively adding microtasks.

对于microtask跟macrotask的不一样点,到这里已经阐述得差很少了。还有一个值得强调的是,要想理解event loop的运行机制,理解microtask/macrotask的入队时机也是十分重要,而且须要额外注意的一点。microtask/macrotask的入队时机是掌握在web API手上的。关于这一点,Philip Roberts在他的演讲中有提到过。在这里,会给出一个示例:

const timeout1 = 0;
const timeout2 = 0;
const timeout3 = 0;

// 代码块(1)
setTimeout(()=>{
    console.log(1);
}, timeout1);

// 代码块(2)
new Promise(resolve=> {
    setTimeout(()=> {
        resolve('finished');
    },timeout2);
    
    // 能够尝试把setTimeout wrapper去掉
    // resolve('finished');
}).then(()=> {
    console.log(2)
    throw new Error("error");
}).catch((e)=> {
    console.log(3);
}).finally(()=> {
    console.log(4);
});

// 代码块(3)
setTimeout(()=>{
    console.log(5);
}, timeout3);
复制代码

你能够经过如下一种或者多种结合的方式来观察一下入队时机是如何影响执行流的:

  1. 任意修改变量timeoutxxx的值;
  2. 切换同步resolve promise或异步resolve;
  3. 调整代码块的书写顺序;

提示1:记得,这个时候,必定要想起web API这个扫地僧啊。

提示2:构造promise实例的代码是同步代码。

setImmediate,MutationObserver和async...await

setImmediate

首先,咱们来聊聊setImmediate。在MDN上,开门见山了指出这个方法并非标准规范要求实现的方法:

This feature is non-standard and is not on a standards track. Do not use it on production sites facing the Web: it will not work for every user. There may also be large incompatibilities between implementations and the behavior may change in the future.

This method is not expected to become standard, and is only implemented by recent builds of Internet Explorer and Node.js 0.10+. It meets resistance both from Gecko (Firefox) and Webkit (Google/Apple).

也就是说,这个特性当前不是标准方法,它的发展也不在能够标准化的方向上。当前只有最新的(相对于Feb 22, 2020)IE版本和Node.js 0.10+上实现了它。 它的标准化进程受到了Gecko (Firefox) 和 Webkit (Google/Apple)的抵制。故而,请不要在生产环境中使用它。

题外话:当我在掘金域名下的页面的控制台输入“set....”的时候,它居然提示有这个“setImmediate”API,而且也是能执行的。我当时就懵掉了,难道在最新版本的chrome中,它实现了这个方法?后面通过摸索(就简单地用“setImmediate.toString()”来看看,原来它并非原生方法,应该是掘金本身引入了外部的polyfill。

虽然它不是标准方法,可是考虑到nodejs有这个方法,而且有个别面试官的“丧心病狂”,咱们不妨看看这个方法究竟是怎么一个回事。

MDN上对它的介绍是这样的:

This method is used to break up long running operations and run a callback function immediately after the browser has completed other operations such as events and display updates.

在这段介绍里面,咱们没有看到这里面有提到setImmediate跟(window)event loop的关系。咱们只看到了,它会在“event callback”和 UI更新等操做后面执行。为了弄清楚调用它的时候传入的function究竟是入队到哪一个队列中,咱们来看看市面上各类setImmediate polyfill是如何解读它的。

咱们挑一个star最多的,也就是第一个“YuzuJS/setImmediate”来看看,只见它的readme里面是这么写的:

The setImmediate API, as specified, gives you access to the environment's task queue, sometimes known as its "macrotask" queue. This is crucially different from the microtask queue used by web features such as MutationObserver, language features such as promises and Object.observe, and Node.js features such as process.nextTick.

第一句话就很明确地指出,它所入队的队列是macrotask queue。一样,在stackoverflow上面的这个问题的一个高分答主也秉持一样的观点:

鉴于,在个人电脑上只有Microsoft Edge(v44.18362.329.0),而且它原生实现了setImmediate方法:

那么咱们就在上面把示例代码跑起来看看,先来个简单版:

setTimeout(() => {
    console.log('setTimeout');
});

setImmediate(()=> { console.log('setImmediate')});

Promise.resolve().then(()=> {
 console.log('Promise');
});

//output:
Promise
setImmediate
setTimeout
复制代码

从这一次的运行结果来看,setImmediate入队的任务要么是追加到microtask queue的后面,要么就插队到macrotask queue的最前面了。这二者都有可能。可是随着深刻试验,我有个惊奇的发现。先卖个关子,咱们先看看下面两个代码运行结果截图:

从这个运行结果来看,咱们仍是没法 判断setImmediate入队的任务到底归属于那个任务队列。可是咱们能够有一个结论,那就是:(1)多个setImmediate的入队顺序仍是按照它们在代码书写期的顺序来入队的,它没有后来者居上的插队表现。 可是,从下面这运行结果咱们就能够大概看出个端倪来:

首先,咱们姑且把一样代码会有不一样的执行结果这个发现放在一边。咱们能够看到,两次的setTimeout居然在两次setImmediate前面打印出来了。这就证实了:(2)setImmediate入队的任务是归属于macrotask queue的。 为何呢?由于假如setImmediate入队的任务是归属于microtask queue的话,那么这段代码不管执行多少次都不会出现第二张截图所显示的运行结果。第二张截图所显示的运行结果证实了setImmediate入队的任务确定是归属于macrotask queue的,可是综合两次运行结果来看,咱们基本能够判断:(3)setImmediate和setTimeout的入队顺序没法获得保证。不过绝大部分的状况下,都是setImmediate入队在先。 咱们伟大的扎叔在他的技术博客里面也提到过:

Another advantage is that the specified function executes after a much smaller delay, without a need to wait for the next timer tick. That means the entire process completes much faster than with using setTimeout(fn, 0).

这种不一致表现,好像有点似曾相识,好像哪里见过,是吧?对的,就是咱们亲爱的nodejs。nodejs在本身的官方文档setImmediate() vs setTimeout()中对于这种不肯定性如是说道:

The order in which the timers are executed will vary depending on the context in which they are called. If both are called from within the main module, then timing will be bound by the performance of the process (which can be impacted by other applications running on the machine).

因此,咱们不妨作个大胆的推测:(4)Microsoft Edge对setImmediate的实现机制跟nodejs对setImmediate实现机制是大体相仿的。

最后,咱们来看最后一个例子:

从个示例代码的运行结果来看, (5)虽然setImmediate()和setTimeout()所入队的任务都在一个macrotask里面,可是不管书写代码的顺序如何,二者都不会交叉入队。 也就是说,使用同一个方法入队的多个任务,要么不执行,要么就一块儿执行。

网上对于(浏览器环境下的)非标准的setImmediate的研究资料和技术文章着实少。相对权威点的资料我查到三个:

  1. w3c的API标准文档
  2. 解释为何在浏览器实现这个API的缘由:兼顾callback的执行频率和电耗性能;
  3. 扎叔的技术文章:humanwhocodes.com/blog/2011/0…

好了,对非标准的setImmediate在event loop中的表现的探索到此为止,有空可继续深刻。

MutationObserver

经过MutationObserver接口咱们可以去监听DOM树的各类更改。引入该特性是为了替代DOM3事件规范的Mutation Event3特性。MDN文档中如是说。

关于MutationObserver接口的语法以及如何在监听DOM树更改领域的应用,本文不打算深刻讲解。本文只是探索经过它来入队的任务是如何参与到event loop中去的。为了此目标,咱们不妨基于它来封装这样的一个方法:

function queueMicrotaskWithMutationObserver(callback){
   const div = document.createElement('div')
    let count = 0
    const observer = new MutationObserver(() => {
        callback && typeof callback === 'function' && callback.call(null)
    })

    observer.observe(div, { attributes: true })
    div.setAttribute('count', ++count);
}
复制代码

好的,有了它,咱们就能够愉快地玩耍了。咱们来看看下面这个示例:

基本上能够,肯定经过MutationObserver来入队的任务是属于microtask。这与网上盛传的说法是一致的。为了进一步验证,咱们再看看复杂一点示例:

咱们能够经过各类更加复杂的示例来观察过MutationObserver在event loop中的表现。咱们会发现,它并不具有比其余接口更高的优先级,它跟Promise和queueMicrotask等接口在入队方面的表现彻底同样的。经过它来入队的microtask的执行方式同样具备“批量性和连续性”。

async...await

限于篇幅的缘由,我在这里就不深刻探讨async...await了。也就是说,不会经过深刻分析async...await的实现原理来探索它在event loop中的表现以及为何这样表现。咱们只须要记住一个当前的事实就是:async...await是promise的语法糖。因此,在这一小节,咱们经过desugar来理解它在event loop中的表现。

咱们结合具体的示例来谈谈如何desugar:

const response = await fetch(…);
const json = await response.json();
const foo = JSON.parse(json); 
console.log(foo);
复制代码
fetch(…)
  .then(response => response.json())
  .then(json => {
    const foo = JSON.parse(json);
    console.log(foo);
  });
复制代码

desugar一个await,基本能够按三部步走:

第一步,将await所在的语句以后,(块/函数/全局)做用域底部边界以前的全部语句都封装到一个callback函数里面:

const callback = (json) => {
    const foo = JSON.parse(json); 
    console.log(foo);
};
复制代码

第二部步,将await关键字所在的语句改造为promise...then:

2. response.json().then();
复制代码

第三步,将callback装进then方法里面:

response.json().then((json) => {
    const foo = JSON.parse(json); 
    console.log(foo);
});
复制代码

desugar多个await的顺序应该由下到上地应用上面的“算法”。那么咱们继续往上desugar的话,应该是这样的:

第一步:

const callback = (response) => {
    response.json().then((json) => {
        const foo = JSON.parse(json); 
        console.log(foo);
    });
};
复制代码

第二步:

fetch(…).then();
复制代码

第三步:

fetch(…).then((response) => {
    response.json().then((json) => {
        const foo = JSON.parse(json); 
        console.log(foo);
    });
});
复制代码

为了代码结构变得更扁平,咱们把上面嵌套调用then的代码风格改成链式调用then的代码风格:

fetch(…).
    then(response => response.json())
    then((json => {
        const foo = JSON.parse(json);
        console.log(foo);    
    });
复制代码

到了这里,咱们就基本把多个“await”desugar为一个“promise...then”的代码。至于async关键字标志的函数其实就是构造promise对象时传入构造函数的executor,而后函数的return值就是promise的resolve值。好比如下的async标志的函数:

async function foo(){
    return 'bar';
}
复制代码

那么它就会被desugar为:

new Promise(res=> {
    res('bar');
})
复制代码

好,讲完如何将async...await转换为promise写法后,咱们在一个例子上面验证一下:

console.log('script start')

async function async1() {
    console.log('async1 start')
    await async2()
    console.log('async1 end')
}
async function async2() {
    console.log('async2 start')
    return Promise.resolve().then(()=>{
      console.log('async2 end')
    })
}

async1()

setTimeout(function() {
    console.log('setTimeout')
}, 0)

new Promise(resolve => {
    console.log('new promise')
    resolve()
})
.then(function() {
    console.log('promise1')
})
.then(function() {
    console.log('promise2')
})

console.log('script end')

// output:
script start
async1 start
async2 start
new promise
script end
async2 end
promise1
promise2
async1 end
setTimeout
复制代码

而后咱们将其中的async...await降级为promise以后是这样的:

console.log('script start')

function asyn1(){
    new Promise(()=> {
        console.log('async1 start');
        new Promise((res)=> {
            console.log('async2 start');
            // 这里有一个注意点,只有then方法执行完,promise才会resolve掉
            res(Promise.resolve().then(()=>{
              console.log('async2 end');
            }) );

        }).then(()=> {
            console.log('async1 end');
        })
    })
}

asyn1()

setTimeout(function() {
    console.log('setTimeout')
}, 0)

new Promise(resolve => {
    console.log('new promise')
    resolve()
})
.then(function() {
    console.log('promise1')
})
.then(function() {
    console.log('promise2')
})

console.log('script end')
复制代码

从运行结果的截图来看,代码的执行顺序一致的。这里采用了一个取巧的方式去理解async...await在event loop的表现,不能为长远之计。有时间,再回来深刻研究。

(window)event loop的应用

虽然业务型的前端开发中不多须要用到event loop机制,可是仍是有很多场景还真的十分须要它。下面列举一下。

1.须要把某些代码延迟到同步代码执行以后再执行

某种状况下,有些框架/类库虽然提供异步的API,可是却没有提供回调给咱们hook进去,这个时候就须要祭出咱们的杀手锏了:setTimeout。

好比说,早期版本的react的setState方法,并无提供一个callback让咱们hook进去,来获取更新后的state值。可是因为event handler里面的代码是在一个批量更新的事务中,这就致使了这种状况下,setState的执行是“异步”(相对原生的异步行为,这种异步是伪异步)的。这个时候,若是你把获取更新state值的代码写在setState()后面的话,那么你是没法获取到更新后的state值的。好比下面:

import React from 'react';

const Count  = React.createClass({
  getInitialState() {
        return {
            count: 0
        }
   },

  render() {
    return <button onClick={()=> {
    
        this.setState({count: this.state.count + 1});
          
        console.log(this.state.count);
      
    }}>{this.state.count}</button>
  }

  componentDidMount() {
  }
})

export default Count; 
复制代码

按理说,你要想获取更新后的state值的话,你应该在生命周期函数componentDidUpdate里面去获取。可是,假如咱们非要经过写在this.setState({count: this.state.count + 1});以后的代码去获取呢?咱们有什么办法呢?办法仍是有的。就是用setTimeout来包裹一下就好:

render() {
    return <button onClick={()=> {
    
        this.setState({count: this.state.count + 1});
          
        setTimeout(()=> {
            console.log(this.state.count);
        }, 0);
      
    }}>{this.state.count}</button>
  }

复制代码

原理是什么呢?原理有二:

  • setState方法的异步执行只是“伪异步”,或者说只是react这个类库层面的异步,并非真正的异步代码(进入过microtask queue/macrotask queue的才是真正的异步代码)。它仍是在react应用代码的同步的执行流里面。
  • 使用setTimeout可以把获取更新后state值的这个动做变成了一个macrotask,也就是真正的异步代码。而异步代码相比call stack里面的同步代码,老是后执行的。全部的react同步代码执行完以后,state值一定是更新的了,因此这个时候再去执行异步代的话,咱们是可以获取到组件最新的状态值。

这里须要强调的是,只提setTimeout只是为了抛砖引玉。在这种场景下,任何把()=> { console.log(this.state.count);}入队到microtask queue/macrotask queue的方法都是可行的。好比说,setInterval,promise,queueMicrotask等等API都是可行的。在这个需求之下,入队到microtask queue仍是入队到 macrotask queue,其实区别都不打,咱们只须要使之变为异步代码便可。

这里拿react的setState方法举例子也只是抛砖引玉,全部有这种需求的场景,咱们均可以用这种方法实现咱们的需求。

2.对一些耗时,阻塞主线程(call stack)的任务进行切片

在不借助web worker的状况,咱们如何在主线程去执行一些本来耗时,阻塞主线程的任务(好比CPU-hungry task)呢。答案是基于event loop的运行机制去作任务切片。至于什么是阻塞主线程,阻塞主线程会有什么后果,本文就不赘述了。详情看Event Loop究竟是什么鬼?。首先咱们来看看下面这个示例1:

<body>
    <input type="text" placeholder="我是input,试一试点击我" style="width: 100%;"/>
</body>

<script>
    window.onload = function(){
        let i = 0;
    
        let start = Date.now();
        
        function count() {
        
          // do a heavy job
          for (let j = 0; j < 1e9; j++) {
            i++;
          }
        
          console.log("Done in " + (Date.now() - start) + 'ms');
        }
        
        count();
    }
    
</script>
复制代码

把这个页面代码运起来以后,你会发现,在控制台打印结果出来以前,页面上的input框是点不动(没法获取焦点)的。这是由于count()的执行一直在占用call stack,致使render callback没法放到call stack去执行。这就是所谓的“阻塞主线程”。如今咱们使用setTimeout来对count()这个大任务进行切片(示例2):

<body>
    <input type="text" placeholder="我是input,试一试点击我" style="width: 100%;"/>
</body>

<script>
    window.onload = function(){
        let i = 0;
    
        let start = Date.now();
        
        function count() {
          do {
              i++;
            } while (i % 1e6 != 0);
            
            if (i == 1e9) {
              console.log("Done in " + (Date.now() - start) + 'ms');
            } else {
              setTimeout(count); // schedule the new call
            }
        }
        
        count();
    }
复制代码

通过切片后,这段代码的执行流是这样的:

  1. 执行1~1000000的累加。在i === 1000000的时候,把下一轮(1000001 ~ 2000000)的累加推入到macrotask queue中;
  2. event loop去render callback queue中pop一个渲染任务出来执行,以此响应用户的交互;
  3. 执行1000001 ~ 2000000的累加。在i === 2000000的时候,把下一轮(2000001 ~ 3000000)的累加推入到macrotask queue中;
  4. event loop去render callback queue中pop一个渲染任务出来执行,以此响应用户的交互;
  5. ......以此类推,直到累加到1e9。

其本质就是经过macrotask和render callback的交替执行来防止这个耗时的大任务来阻塞call stack。虽然切片以后,累加到1e9的所用的总时间变长了,可是咱们保证了界面的可交互性,因此,这一点时间代价不值一提。

若是你想基于示例2去继续优化,想要它既不阻塞call stack,有可以尽可能地缩短它的执行时长,方法也是有的。下面,咱们来看看示例3:

<body>
    <input type="text" placeholder="我是input,试一试点击我" style="width: 100%;"/>
</body>

<script>
    window.onload = function(){
        let i = 0;
    
        let start = Date.now();
        
        function count() {
          if(i < 1e9 - 1e6){
              setTimeout(count); // schedule the new call
          }
          
          do {
              i++;
            } while (i % 1e6 != 0);
            
            if (i == 1e9) {
              console.log("Done in " + (Date.now() - start) + 'ms');
            }
        }
        
        count();
    }
复制代码

在这个示例中,咱们入队动做放在累计以前,经过这样作,咱们能把任务的耗时从示例2的12秒左右减小到8秒左右。这是为何呢?

这是由于,虽然咱们setTimeout()调用没有传递time out时间,即为默认值0。可是HTML5规范要求,嵌套执行的setTimeout的最小延迟时间为4ms,即便咱们显式设置为0也不行。大部分浏览器也是这么实现的。当咱们把入队的动做放在最前面的时候,在每一轮累加中,咱们就能省掉4毫秒。1e9/1e6 = 1000(轮),因此咱们能省掉的时间大概为 1000 * 4 = 4000(ms)。这也是跟我 们的实际执行结果相吻合的。至于,把入队的动做放在最前面以后,为何每轮能够省掉4毫秒呢?你大可依据上面给出的event loop处理模型图来进行推断,这个练习就留给你本身了。

3.保证代码在不一样的状况下,保持一致的执行顺序

这种场景通常是特指在不一样的条件分支里面,一个分支用了异步代码,一个分支没用异步代码,从而致使在不一样的状况下,代码的执行顺序无法保持一致。请看下面这个例子:

customElement.prototype.getData = url => {
  if (this.cache[url]) {
    this.data = this.cache[url];
    this.dispatchEvent(new Event("load"));
  } else {
    fetch(url).then(result => result.arrayBuffer()).then(data => {
      this.cache[url] = data;
      this.data = data;
      this.dispatchEvent(new Event("load"));
    });
  }
};

element.addEventListener("load", () => console.log("Loaded data"));
console.log("Fetching data...");
element.getData();
console.log("Data fetched");
复制代码

在这里,假设咱们是想对特定的接口作数据缓存。当你第一次执行的时候,程序会执行第二个条件分支,打印结果是这样的:

Fetching data
Data fetched
Loaded data
复制代码

当你第二次执行的时候,程序会执行第一个条件分支,打印结果将会这样:

Fetching data
Loaded data
Data fetched
复制代码

由于代码的执行次序没法获得保证,这就会增长了代码运行的不可预测性,从而理解和维护成本变得更高。为了解决这个问题,咱们可使得两个条件分支的都是异步代码便可:

customElement.prototype.getData = url => {
  if (this.cache[url]) {
    queueMicrotask(() => {
      this.data = this.cache[url];
      this.dispatchEvent(new Event("load"));
    });
  } else {
    fetch(url).then(result => result.arrayBuffer()).then(data => {
      this.cache[url] = data;
      this.data = data;
      this.dispatchEvent(new Event("load"));
    });
  }
};
复制代码

其实,这种解决方案对全部的条件语句都适用,不必定是if...else。具体使用的API,也不必定是queueMicrotask和promise...then,只要保证两个条件分支的代码都编排成同种类型的task便可。

4.合并情趣,批量执行任务

有时候咱们须要批量做业,即将屡次连续的操做请求合并为一次的操做请走,最终实际执行一次操做。咱们来看看下面的代码:

const messageQueue = [];

let sendMessage = message => {
  messageQueue.push(message);

  if (messageQueue.length === 1) {
    queueMicrotask(() => {
      const json = JSON.stringify(messageQueue);
      messageQueue.length = 0;
      // 这里用console.log来模拟实际的操做
      console.log('最终要操做的数据的json序列是:', json)
    });
  }
};


sendMessage(1);
sendMessage(2);
sendMessage(3);
sendMessage(4);
sendMessage(5);


// output:
// 最终要操做的数据的json序列是: [1,2,3,4,5]
复制代码

这里的原理就是:event loop和闭包。 在屡次连续的sendMessage()调用中,咱们经过判断来确保在第一次调用的时候就把一个函数编排为microtask,并入队到microtask queue中(此处,不必定用“messageQueue.length === 1”这种判断条件,咱们也能够用标志位来实现)。此时,咱们已经把变量messageQueue保存在函数闭包中了。在后续的sendMessage()调用,咱们其实就是操做这个闭包中的变量(闭包变量会相对地常驻内存)。就像上面所说的那样,代码片断的初始执行自己就是一个macrotask,当这个macrotask在call stack执行完毕后(即call stack处于清空状态),此时event loop发现microtask queue上有一个microtask,因而乎就把它推入到call stack去执行。在执行microtask以前,messageQueue变量的值其实已是数组类型的[1,2,3,4,5],那么最后在microtask操做数据的时候确定没问题了。其实上面的调用代码就等同于:

const messageQueue = [];
messageQueue.push(1);
messageQueue.push(2);
messageQueue.push(31);
messageQueue.push(4);
messageQueue.push(5);
const json = JSON.stringify(messageQueue);
messageQueue.length = 0;
console.log('最终要操做的数据的json序列是:', json)
复制代码

咱们能够借鉴这里的思想,模仿实现原生react中setState方法的行为表现:异步和批量:

const componentInstance = {
    state:null,
    _pendingState:null,
    _stateList:[],

    render(){
        console.log('reconciling...');
    },

    setState(partialState){
        this._stateList.push(partialState);

        if(this._stateList.length === 1) {
            queueMicrotask(() => {
                if(this.state !== null){
                    this._stateList.unshift(this.state);
                }

                const finalState =  this._stateList.reduceRight((prev,curr)=>{
                    return Object.assign(curr,prev);
                },{})
                this._pendingState = finalState;
                console.log('reconciliation start...')
                this.render();
                console.log('reconciliation end...')
                this.state = this._pendingState;
                this._pendingState = null;
                this._stateList.length = 0;
            });
        }
    }
}

复制代码

首先,咱们来考察上面实现的异步表现:

componentInstance.state = {count: 1};
componentInstance.setState({count: componentInstance.state.count + 1});

// 能拿到更新后的state值吗?
// 打印结果:state: 1,因此答案是:不能。
console.log('state:', componentInstance.state);

// 能拿到更新后的state值吗?
// 打印结果:state: 2,因此答案是:能。
setTimeout(() => {
    console.log('state:', componentInstance.state);
}, 0);
复制代码

而后,咱们在来考察上面实现的批量更新表现:

componentInstance.state = {count: 1};
componentInstance.setState({count: componentInstance.state.count + 1});
componentInstance.setState({count: componentInstance.state.count + 1});
componentInstance.setState({count: componentInstance.state.count + 1});

// output: state: 2,证实是批量更新
setTimeout(() => {
    console.log('state:', componentInstance.state);
}, 0);
复制代码

event loop在javascript异步编程领域下,应该还要不少的应用场景,期待有更多的发掘。

nodejs中的event loop(待续)

event loop的面试题

面试题难度的几个层级:

  1. 比较生冷的考法,好比考你以此几点:
    • macrotask,microtask和render callback执行的前提是call stack为空;
    • 三个队列执行的优先级:microtask > render callback > macrotask;
  2. 理解macrotask与microtask执行的前后顺序;
  3. 理解microtask执行的批量性,连续性;
  4. 理解入队时机对执行流的影响,理解promise对象的构造代码是同步执行的。
  5. 掌握比较冷僻的,setImmediate,async...await和mutationObserver;

好下面,咱们来看看市面上面试题:

问题1: 如下的三个场景的执行结果会是怎样?为何?

// 场景1:
function foo() {
  setTimeout(foo, 0); 
};
foo();

// 场2:
function foo() {
  return Promise.resolve().then(foo);
};
foo();

// 场景3:
function foo() {
  foo() 
};
foo();

复制代码

解析:

  • 场景1会无限递归执行,js引擎不会报“maximum call stack size exceeded”,同时界面可以响应用户的交互;由于, macrotask与microtask执行的前提是call stack为空。call stack同一时间里面只有一个call frame;界面之因此可以响应用户交互是由于用户经过交互产生的各类render callback的优先级比macrotask的优先级要高,意思是优先响应UI。
  • 场景2也会无限递归执行,js引擎不会报“maximum call stack size exceeded”,可是界面不能够响应用户的交互;不能报“maximum call stack size exceeded”的缘由跟场景1是同样的。界面不可以响应用户交互是由于microtask的优先级比render callback的优先要高,这样子的话,连续,无限的microtask执行就长期占用了call stack,使得render callback没法获得执行的机会,界面也就无法从新渲染了(为了验证这个场景的执行结果,把当前的render process搞崩了好几回)。
  • 场景3没法递归执行,js引擎会报“maximum call stack size exceeded”。这是同步代码,每递归一次,就会增长一个call frame,因此一定会引发call stack长度溢出。

问题2: 如下的打印顺序结果会是怎样的呢?:

setTimeout(function() { 

 console.log(1)}, 0);

new Promise(function executor(resolve) { 

 console.log(2);  

for( var i=0 ; i<10000 ; i++ ) { 

   i == 9999 && resolve(); 

 } 
 console.log(3);

}).then(function() {  

console.log(4);

});

console.log(5);

复制代码

解析: 打印结果是:

2
3
5
4
1
复制代码

考点有:

  • event loop的基本处理模型
  • promise的executor是属于同步代码,即归属于js引擎初始化后的第一个macrotask。
  • 题中的for循环一直在占用call stack,因此,后面的“console.log(5);”也无法执行。
  • promise一旦resolve掉,相应的callback才能入队到microtask中。

问题3: 如下的打印顺序结果会是怎样的呢?:

// 位置 1
setTimeout(function () {
  console.log('timeout1');
}, 1000);

// 位置 2
console.log('start');

// 位置 3
Promise.resolve().then(function () {
  // 位置 5
  console.log('promise1');
  // 位置 6
  Promise.resolve().then(function () {
    console.log('promise2');
  });
  // 位置 7
  setTimeout(function () {
    // 位置 8
    Promise.resolve().then(function () {
      console.log('promise3');
    });
    // 位置 9
    console.log('timeout2')
  }, 0);
});

// 位置 4
console.log('done');
复制代码

解析: 打印结果是:

start
done
promise1
promise2
timeout2
promise3
timeout1
复制代码

这里有好几个考点。首先在考你:

  1. 位置1和位置7到底谁先入队macrotask queue?
  2. 位置6和位置7几乎同时分别入队到microtask和macrotask中,当前的microtask执行完,call stack为空的时候,到底先执行谁?

针对考点1,其实就是考你同一个类型的任务,入队时机的问题。这种问题得具体问题具体分析。不过通常是看如下几点:

  • setTime调用时传入的delay时间值(单位为毫秒);
  • promise被resolve的时机(由于这会影响到后面then方法callback的入队时机);
  • 当两个setTime的delay时间值同样的时候,咱们就看它们在代码书写期的前后顺序,不相等的时候(而且前面代码的执行耗时几乎能够忽略不计),那么咱们就比较它们时间值得大小。越小越早入队。
  • 要是当前入队动做发生前时候有同步代码阻塞call stack,注意评估该同步代码的执行时间。

拿setTimeout这个入队动做举个例子,两个setTimeout的入队顺序算法以下:

promise也是同样的,只不过它所对应的delay时间是由resolve方法执行的时间点来决定的。

回归到本示例,由于位置7前面的同步代码的执行时间几乎忽略不计,而位置1总的delay时间则为1000毫秒。因此,最早入队的是位置7。假如,咱们把位置7的delay时间改成1001ms的话,那么打印结果将会是这样的:

start
done
promise1
promise2
timeout1
timeout2
promise3
复制代码

能够看出,“timeout1”在前面,“timeout2”在后面。具体的执行结果截图就不给出,你们能够自行去验证。

为了测试咱们算法的准确性,那咱们再来测试一下在delay时间相等的状况:

// 位置 1
setTimeout(function () {
  console.log('timeout1');
}, 1000);

// 位置 2
console.log('start');

// 位置 3
Promise.resolve().then(function () {
  // ....
  
  setTimeout(function () {
    // 位置 8
    Promise.resolve().then(function () {
      console.log('promise3');
    });
    // 位置 9
    console.log('timeout2')
  }, 1000);
});

// 位置 4
console.log('done');
复制代码

那么打印结果将会是:“timeout1”在前面,“timeout2”在后面。若是咱们调换一下二者的书写顺序:

// 位置 2
console.log('start');

setTimeout(function () {
    // 位置 8
    Promise.resolve().then(function () {
      console.log('promise3');
    });
    // 位置 9
    console.log('timeout2')
  }, 1000);

// 位置 1
setTimeout(function () {
  console.log('timeout1');
}, 1000);

// 位置 4
console.log('done');
复制代码

那么打印结果将会:“timeout2”在前面,“timeout1”在后面。为了证实咱们算法的准确性,咱们最后来验证一下“同步代码的执行时间不能忽略不计的状况”。咱们有如下代码:

// 位置 1
setTimeout(function () {
  console.log('timeout1');
}, 1000);

// 位置 2
console.log('start');

// 位置 3
Promise.resolve().then(function () {
  // 位置 5
  console.log('promise1');
  // 位置 6
  Promise.resolve().then(function () {
    console.log('promise2');
  });
  
  // 阻塞2ms
  const now = Date.now();
  while(Date.now() - now < 3){}
  
  // 位置 7
  setTimeout(function () {
    // 位置 8
    Promise.resolve().then(function () {
      console.log('promise3');
    });
    // 位置 9
    console.log('timeout2')
  }, 999);
});

// 位置 4
console.log('done');
复制代码

以上代码中,虽然位置7自己的delay时间比位置1的delay时间少了1毫秒,可是位置7前面在call stack上阻塞了2ms,那么位置7的入队所用总时间 = 999 + 2 = 1001(ms)。1000 < 1001,因此,位置1先入队。最终打印结果将会:“timeout1”在前面,“timeout2”在后面。执行结果截图为证:

对于promise而言,只要把delay之间改成resolve所须要的时间便可,在这里就很少加讨论了。通常而言,面试不会出一些那么牛角尖的题目,可是若是咱们本身提早深刻到这一点话,那么咱们就可以应付得了一些丧心病狂的面试题。

针对考点1已经解释完了,那么看看考点2。哎,其实考点2也没有啥好说的,就是在考microtask的连续性。换句话说,要是同时入队两个任务,一个是macrotask,一个microtask,那么接下来要执行的确定是microtask。

针对同一个示例,咱们能够根据上面给出的面试题考点来举一反三地改造它,而后在浏览器的控制台运行起来,看看代码的执行结果跟本身推演的结果是否一致就能够。多加练习,相信你会愈来愈有信心,对(window)event loop的理解也会更加深刻的。

总结

参考资料

  1. Event loop: microtasks and macrotasks
  2. Concurrency model and the event loop
  3. tasks-microtasks-queues-and-schedules
  4. event-loop-processing-model;
  5. In depth: Microtasks and the JavaScript runtime environment
  6. General asynchronous programming concepts;
  7. Using microtasks in JavaScript with queueMicrotask();
  8. Does async/await blocks event loop?;
  9. 通杀 Event Loop 面试题
  10. Explore the Magic Behind Google Chrome
相关文章
相关标签/搜索