一次性整明白Javascript代码的执行顺序

1,756 阅读11分钟

1 前言

在前端开发过程中,我们通常会遇到这样的问题:为啥有时写的代码没有语法、“逻辑”等基础错误时,就是没有按照期望的执行?明明在使用某个变量前进行了“赋值”,但是就是获取不到想要的值呢?这时候就有可能是存在隐藏的代码逻辑问题(对JavaScript的事件循环模型没有足够的理解)。为了帮助大家解决困惑,我将对JavaScript事件循环(浏览器环境下)做详细的介绍,希望对大家有所帮助。

2 JavaScript运行机制

2.1 介绍

众所周知JavaScript是⼀⻔单线程的语⾔,也就是说在同⼀个时间节点只能做⼀件事情,而这个单线程的特性,与它的用途有关,作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?这种设计有利就有弊,这个特性造成了JavaScript这⻔语⾔的⼀些局限性,⽐如在我们的⻚⾯中加载⼀些远程数据时,如果按照单线程同步的⽅式运⾏,⼀旦有HTTP请求向服务器发送,就会出现等待数据返回之前⽹⻚假死的效果出现。因为JavaScript在同⼀个时间只能做⼀件事,这就导致了⻚⾯渲染和事件的执⾏,在这个过程中⽆法进⾏。显然在实际的开发中我们并没有遇⻅过这种情况。

2.2 同步和异步

基于上面的描述,既然实际开发中并没有出现上面的弊端,那么就应该存在一种解决方案——同步(阻塞)和异步(非阻塞)执行模式。

同步(阻塞):

同步的意思是JavaScript会严格按照单线程(从上到下、从左到右的⽅式)执⾏代码逻辑,进⾏代码的解释和运⾏。那为何又是阻塞运行呢,我们看下以下代码的运行场景:

当我们按照顺序执⾏上⾯代码时,执行到循环时,程序需要2秒才能跳出循环,进⽽再输出a的结果。那么这段程序的实际执⾏时间⾄少是2秒以上。这就导致了程序阻塞的出现,这也是为什么将同步的代码运⾏机制叫做阻塞式运⾏的原因。

阻塞运行的代码在遇到耗时的程序代码段时,之后的执行都需要等待耗时代码片段执行完才能获取到执行资源,这就是单线程同步的特点。

异步(非阻塞):

在上面的同步介绍中,我们知道了同步存在的问题,接下来介绍下异步的特点。异步就是和同步对立,异步模式的代码是不会按照顺序执行的,如下例子:

上面代码中的异步执行并没有阻塞同步代码的执行,当遇到同步代码片段时,执行引擎会先将异步程序保存到暂存区,等待所有同步代码执行完毕后,异步的代码就会按照特定的顺序执行,这就是单线程异步的特点。

2.3 浏览器线程组成

上⾯我们通过⼏个简单的例⼦⼤概了解了⼀下JS的运⾏顺序,那么为什么是这个顺序,

异步又是怎么实现的呢?这⾥先介绍⼀下浏览器内核的实际线程组成。虽然浏览器是单线程执⾏JavaScript代码的,但是浏览器实际是以多个线程协助操作来实现单线程异步模型的,具体线程组成如下:

  1. GUI渲染线程(负责渲染浏览器界面HTML元素)

  2. JavaScript引擎线程(主要负责处理Javascript脚本程序,例如V8引擎)

  3. 事件触发线程(当一个事件被触发时该线程会把事件添加到待处理队列的队尾)

  4. 定时器触发线程(setInterval与setTimeout所在线程)

  5. http请求线程(ajax所在线程)

  6. 其他线程

按照真实的浏览器线程组成分析,我们会发现,真正用来执行Javascript的线程只有JavaScript引擎线程,所以说JavaScript是单线线程的。那么其他线程在JavaScript的执行过程中是否有参与?起到什么作用呢?答案是肯定的,因为单个线程是很难实现异步操作的,这里其它线程就可以发挥协同、调度的作用,以实现JavaScript的异步执行。为了方便分析,将上⾯的细分线程归纳为下列两条线程:

  1. 【主线程】:这个线程⽤了执⾏⻚⾯的渲染,JavaScript代码的运⾏,事件的触发等等

  2. 【⼯作线程】:这个线程是在幕后⼯作的,⽤来处理异步任务的调度来实现⾮阻塞的运⾏模式

3 事件循环模型

了解了浏览器的线程组成后,接下来我们更深入探究JS的执行问题——事件循环。

3.1 JavaScript运行时

在执行JavaScript 代码的时候,JavaScript 运行时实际上维护了一组用于执行 JavaScript 代码的代理。每个代理由一组执行上下文的集合、执行上下文栈、主线程、一组可能创建用于执行 worker 的额外的线程集合(本文统称工作线程)、一个任务队列以及一个微任务队列构成。除了主线程(某些浏览器在多个代理之间共享的主线程)之外,其它组成部分对该代理都是唯一的。为了方便理解,对以下图进行分析。

根据图中可知,主线程是执行Javascript代码的线程,当遇到同步任务时,直接将该任务放到函数执行栈中执行,当遇到异步任务时,将该任务交给工作线程进行调度放进任务队列等待执行,当所有同步任务执行完成后,也就是执行栈中的任务都执行完了,这时事件循环就会开始工作,检查任务队列中的任务,存在任务就进执行栈执行,在执行过程中遇到异步任务时,还是放到工作线程进行调度入队,以此反复,直到任务队列为空,执行栈为空。

函数执行栈( Stack):

执⾏栈是⼀个栈的数据结构,函数调用形成了一个由若干帧组成的栈。

在上面的代码中,当调用bar 时,第一个帧被创建并压入栈中,帧中包含了 bar 的参数和局部变量。 当 bar 调用 foo 时,第二个帧被创建并被压入栈中,放在第一个帧之上,帧中包含 foo 的参数和局部变量。当 foo 执行完毕然后返回时,第二个帧就被弹出栈(剩下 bar 函数的调用帧 )。当 bar 也执行完毕然后返回时,第一个帧也被弹出,栈就被清空了。

1、栈溢出问题:

通过了解函数执行栈的工作方式后,我们不难发现,如果出现大量的函数嵌套(比如递归)的话,栈中就会堆积栈帧,如果超出执行栈的深度(不同的浏览器和JS引擎执行栈的深度可能会有所区别)的话就会造成栈溢出,比如下面这个例子(Chrom浏览器下测试):

执行后,我们发现在递归了11418次之后会提示超过栈深度的错误,也就是我们⽆法在Chrome或者其他浏览器做太深层的递归操作

2、跨越递归限制

那么有没有什么方法解决递归造成的栈溢出问题呢?将代码做如下更改(不推荐使用,只是用来测试方便理解JS执行机制):

经过测试后我们发现超出了次数也没报错,已经不存在栈溢出问题了,这个是因为我们这⾥使⽤了异步任务去调⽤递归中的函数,有了异步任务之后我们的递归就不会叠加栈帧了,因为放⼊⼯作线程之后该函数就结束了,可以出栈销毁,那么在执⾏栈中就永远都是只有⼀个任务在运⾏,这样就防⽌了栈帧的⽆限叠加,从⽽解决了⽆限递归的问题,不过异步递归的过程是⽆法保证运⾏速度的,在实际的⼯作场景中,如果考虑性能问题,还需要使⽤while循环等解决⽅案,来保证运⾏效率的问题,在实际⼯作场景中,尽量避免递归循环,因为递归循环就算控制在有限栈帧的叠加,其性能也远远不及指针循环。

堆( Heap):

堆内存,程序运行时涉及到的对象数据存放区域

任务队列( Queue):

一个 JavaScript 运行时包含了一个待处理消息的消息队列(任务队列)。每一个消息都关联着一个用以处理这个消息的回调函数。

在事件循环期间的某个时刻,运行时会从最先进入队列的消息开始处理队列中的消息。被处理的消息会被移出队列,并作为输入参数来调用与之关联的函数。正如前面所提到的,调用一个函数总是会为其创造一个新的栈帧。

函数的处理会一直进行到执行栈再次为空为止;然后事件循环将会处理队列中的下一个消息(如果还有的话)。

3.2 事件循环( Event Loop)

4 任务队列

在了解事件循环模型过程中,我们认识了一个任务队列的东西,任务队列中存在两种类型的任务,一种是宏任务一种是微任务。

如上图所示,在JavaScript执行过程中,每次事件循环会先检测任务队列中的微任务,如果存在微任务,就会将微任务放入执行栈中执行,执行过程中产生的微任务会放到本次循环微任务队列队尾等待执行,直到微任务队列为空,才会开始执行本次循环的宏任务,宏任务执行过程中产生的微任务会放到下次循环的微任务队列中,执行完本次循环的宏任务后才会进入下一次事件循环。

4.1 宏任务

宏任务是JavaScript中最原始的异步任务,包括setTimeout、setInterVal、AJAX等,在代码执⾏环境中按照同步代码的顺序,逐个进⼊⼯作线程挂起,再按照异步任务到达的时间节点,逐个进⼊异步任务队列,最终按照队列中的顺序进⼊函数执⾏栈进⾏执⾏。

4.2 微任务

微任务是随着ECMA标准升级提出的新的异步任务,在异步任务队列的基础上增加了微任务的概念,每⼀个宏任务执⾏前,程序会先检测中是否有当次事件循环未执⾏的微任务,优先清空本次的微任务后,再执⾏下⼀个宏任务,每⼀个宏任务内部可注册当次任务的微任务队列,再下⼀个宏任务执⾏前运⾏,微任务也是按照进⼊队列的顺序执⾏的。

4.3 宏任务、微任务对比

注:Event Loop中,每一次循环称为tick

5 验证分析

5.1 实例代码

5.2 代码分析

5.3 实测结果

将验证代码放到浏览器执行,得到输出结果如下图,结果与代码分析结果一致。

6 总结

我们由浅到深,一步步的对JavaScript的执行进行了分析,认识了同步、异步、堆、栈、Event Loop等,并通过实例验证来加强理解,看起来似乎涉及到的东西非常多,比较复杂。但其实只要认真看完总结下,应该并不复杂,可以总结成于以下几个步骤:

1、加载代码

2、按顺序执行同步代码

3、遇到异步任务,挂起等待放入队列

4、同步代码执行完毕

5、检查微任务队列是否为空,若不为空,出队一个微任务进执行栈,回到第2步,若 为空,继续下一步

6、检查宏任务队列是否为空,若不为空,出队一个宏任务进执行栈,回到第2步,若 为空,执行结束,等事件循环检测到任务队列不为空时再回到5步