JS执行原理详解(同步、异步、宏任务、微任务)
提到 promise 我相信很多小伙伴都不陌生,自 ES6 发布后,被列为正式规范。也是其中最重要的特性之一,而且也是我们平常解决异步、回调等问题的最优解。
不仅如此,相信不少小伙伴面试时,没少遇到询问执行顺序、打印顺序的题了吧?相信最初都被折磨的不清,后来可能通过各种途径了解到了答题技巧。但,有没有好奇过这技巧是怎么来的呢?
我们这次不为别的,就来深挖一下这技巧背后所隐藏着的秘密!
什么是异步?
要了解原理,首先我们要先知道,什么是异步。
我们知道同步执行是一行一行从上往下执行代码,从字面上来讲,这个 “异” 好像与 “同” 相对,那是不是异步就是一起执行呢?就比如我 “一边打游戏,一边撸代码”。我游戏通关了,代码也写完了,这就是异步?
不对不对!要知道,JavaScript可是单线程的语言!假设代码执行是一条流水线,那么不管同步还是异步,都是在这一条流水线上工作,唯一的差别就是 执行顺序的不同。
最常见的异步方法,就是 setTimeout 和 setInterval 这两个定时请求了。我们来看下代码
//简单的异步执行
var count = 1;
var timer = setTimeout(function(){
count++;
console.log('in:', count);
}, 1000);
console.log('out:', count);
//循环执行 + 终止
var count2 = 1;
var timer2 = setInterval(function(){
count++;
console.log('in2:', count2);
}, 1000);
console.log('out2:', count2);
setTimeout(() => {
clearInterval(timer2)
console.log('over:', count2)
}, 5000);
上面的代码相信所有小伙伴都看得懂,也知道打印的结果这里就不多说了。从这里就可以看出,执行的过程是先执行同步代码,再去执行异步代码。
依旧用流水线做假设,就好像是在一条流水线上,会优先把属于“同步”的工作做完,再去做“异步”的工作,而且其中也包含跟“同步”一样的规则和任务,有先后顺序有循环有时延,是不是就好像另一条流水线一样?
有一条看不见的队列,存放着它需要默默执行的命令
进程和线程
之前所提到的看不见的队列,就跟进程和线程有关,接下来我们再了解一下什么是进程什么是线程。
概念和区别
学过计算机的小伙伴都知道,这是计算机原理中的一种概念,书本上会有这样一句话来描述他们:
进程是CPU资源分配的最小单位
线程是CPU调度的最小单位
怎么理解?进程其实就相当于是 一段孤立执行的逻辑。线程就是 为了实现这段逻辑可随意调用(归属于这个进程)的颗粒点。这么说可能不好理解,来假设一个场景:
现在疫情严峻,国家要求每个小区都要做核酸(命令),那一个个小区都动员起来,响应国家号召,安排一部分工作人员去拿着核酸用具(资源)给大家做核酸,一部分去拿着大喇叭(资源)喊:“所有人都下来做核酸!”。
这个过程中,一个个的小区就是进程,每个小区都有自己的仓库不,里面存放着一些资源,那些的工作人员就是线程,他们为了完成小区的任务,是需要共同协作的,而协作的资源都是来自小区,他们自己手上是没有独立的资源的,都是共享的小区的资源。所以,进程的特点就是 拥有独立资源。 线程的特点就是 共享,多个线程是共享资源的,这也解释了线程是cpu调度的最小单位,就比如国家下达指令,最终执行人就是那些工作人员。总结一下
进程是有独立资源的,并且能协同多个线程去完成任务。线程是可以参与到这个任务当中,完成自己分内的事,但是可调度的资源是和其它线程共享的。
浏览器原理
浏览器窗口tab页面,就是一个进程,我们的js也是在浏览器上执行,所以说浏览器是非常重要的,了解熟悉一下浏览器原理也是必不可少的。浏览器页面中包含有五个非常关键的线程,接下来我们就好好说道说道
GUI渲染引擎
主要是用于解析我们的模板HTML,CSS这些,并且构建dom树,完善页面的骨架,再绘制出现。
(构建Dom树 => 布局 => 绘制)
但是要注意一点,它是与JS引擎互斥,当执行JS引擎线程时,GUI会pending住,当任务队列空闲时,才会继续执行GUI
JS渲染引擎
这个简单,就是处理JS,解析并执行脚本。(但是执行的操作可不简单)
并且分配、处理、执行待执行的事件,也就是 event队列。
还有会阻塞GUI引擎
定时器触发引擎
对异步定时器处理与执行 - setTimeout 和 setInterval
接收JS引擎分配的定时器任务,并计数,处理完成后交付给事件触发引擎触发
异步HTTP请求线程
异步执行请求类处理,比如:Promise/ajax 等
接收JS引擎分配的异步HTTP请求,监听回调,交给事件触发引擎触发
事件触发引擎
接收来源:定时器引擎、异步引擎、用户操作
是作为最终执行者的存在,每个线程都把各种处理好的事件丢给他,由他来统一的接收和调度。
将调度过来的事件依次接入到任务队列的队尾,再还给JS引擎
JS执行原理
到这里我们大多数的线程都解释清楚了,但是呢,这只是一个流程,一个大概,虽然线程是按照这个顺序触发的,但是具体是怎么触发的?就涉及到JS执行原理了,接来下我们再好好看看
JS执行的空间可以分为两大块,一个是 Memory Heap(储存堆),另外一个是 Call Stack(执行栈)
什么是储存堆呢?用来分配内存的,各种可执行的文件、语句都会被分配到内存中,虽然资源有基本类型和引用类型之分,但是所有资源具体存放的地方,都是在内存里。简单来说,就是个仓库,我们所有的代码,文件全都存放进去。
执行栈,就是执行回调的地方。每一个要执行的东西,都会往里存,并且具有一个先入后出的特性。
这么说可能有点抽象,我们来看一段代码
function run(){
fun1();
}
function fun1(){
fun2();
}
function fun2(){
throw new Error('plz check ur call stack!');
}
run();
之前说到过,JS是单线程语言,单步执行,当它每遇到需要执行的东西时,就会把它放到栈的最底部,一个一个放,再交由JS引擎一个一个执行。
以上面代码为例,当代码读到run();这一行时,run会被最先放下去,然后是fun1,最后再是fun2,这个过程是由事件触发引擎做的,也被称为入栈。接下来再交给JS引擎执行,fun2被最先执行,再是fun1,最后是run,这个过程就是出栈。这整个过程就是执行堆栈,也是栈的特点 先入后出 。
这样说可能记忆不深,我们来放浏览器执行一下。
看这张图,会发现先出现的是Error,而Error呢,是从fun2出来的,fun2又是从fun1出来的,fun1又是从run出来的。但我们的执行顺序呢,是先执行run,再执行fun1,最后才是fun2里的Error。
就好比如我们吃桶装薯片一样,我拿出来吃的,永远是最上面的,最先放进去的,永远是最后才拿出来吃的。(不接受某些人强行吃底下薯片的操作)
上面那张图可能有部分小伙伴不太懂,我们换个更浅显的
代码一行一行执行,当遇到
run(); 需要执行的时候,再按照执行栈特点先入后出的顺序依次执行 fun2 -> fun1 -> run,最后执行完run后,再继续执行 run(); 下面的语句。
我们现在所说也只是JS单步执行的一个流程,剩下的几个(定时器、异步以及其它引擎)在应用层面会被我们统称为 WebAPI,比如DOM操作、AJAX、定时器等,它们会直接通过事件触发引擎直接跟JS执行堆栈进行通信。
说了这么多,我们来总结一下(进程、线程的工作原理)
先是由JS给定时器触发引擎和异步HTTP请求发任务,它俩完成后,再还给事件触发引擎,再由事件触发引擎统一给到JS引擎去执行,按照先入后出的顺序依次执行。
任务
之前我们是从宏观到微观的角度(进程、线程->浏览器原理->JS执行原理)初步了解了一下 event队列(任务),接下来我们再从同一纬度(JS执行->任务)了解一下什么是任务。
关于任务我们需要从两个方面去剖析它,从横向角度去看可分为宏任务和微任务,从纵向角度去看可分为同步任务和异步任务。
宏任务和微任务
那什么是宏任务,微任务呢?其实是根据任务处理内容的不同所区分成宏、微任务。
它们有个很大的区别:宏任务是由宿主(Node/浏览器)发起的,微任务则是由JS引擎发起的。
其实在ES3之前,JavaScript本身没有发起异步请求的能力,也就没有微任务的存在。后来ES5之后,引入了 Promise ,这样就不需要浏览器,JS本身也能发起异步任务了。
可能还有小伙伴分不太清,那关于宏、微任务,这里简单列举几个:
宏任务(macro) => <script>整体代码、setTimeout/setInterval、setImmediate、I/O (NodeJs)、DOM操作的回调、UI render 等
微任务(micro) => Process.nextTick (NodeJs)、MutationObserver、Promise.then、catch、finally 等
执行原则:宏任务的优先级永远大于微任务(有微则微,无微则宏)
这执行顺序怎么理解呢,就是:宏任务优先执行,宏任务中有包含微任务的,就执行微任务,执行完成或没有微任务的,再执行下一个宏任务。
我们来看张图加深一下印象
同步和异步
同步和异步,相信各位小伙伴都知道,上面也着重解释了一下,这里就不细说了,接下来重点讲一讲任务的执行机制(Event Loop)也被称之为 “事件循环”。
其实事件循环,就是之前所讲到的 “JS渲染引擎” 所干的活,当遇到任务时会先区分是同步任务还是异步任务,同步任务会直接进入主线程去执行,如果是异步任务,则会进入到 Event Table 中,再由 Event Table 推入 Event Queue,最后再到主线程去执行。
在这里,我们会发现两个概念 Event Table 和 Event Queue,它们是做什么的呢?
我们先来简单了解一下:
Event Table => 注册所有的异步事件,并处理事件进入 event queue 的顺序(通过优先级判断和定时判断)
Event Queue => “事件队列”也被称为“任务队列”,负责接收 event table 传入的异步事件,然后等主线程任务执行完成后,再把队列中的任务交由主线程去执行
那它是怎么知道主线程任务执行完成的呢?
其实JS引擎中存在一个名为 Monitoring Process 的进程,它会持续不断的检查主线程执行栈是否为空,一旦为空,就会去 Event Queue 那里检查是否有等待被执行的任务。而且这个过程是不断循环往复的,所以整个的这种运行机制又被称为 Event Loop(事件循环)
全都是理论性的文字,可能记忆不深,我们来看张图加深一下记忆
这样是否就感觉清楚多了^_^
可能有小伙伴就会问了,那之前的宏任务,微任务又是扮演的什么角色?好像没参与进来呀!
的确,毕竟我们之前都是从不同角度来看待问题的,肯定是有些片面的。接下来我们要把这两个角度结合起来,看一看它们之间的联系。
同、异、宏、微之间的关系
相信大多数小伙伴都有一种感觉:任务分为同步、异步任务,然后异步任务再分为宏、微任务。
实则不然,当然啦也不算错,只是理解的片面了一点
同步和异步是从宏任务的维度通过执行方式维度,区分了任务的执行顺序。
那为什么我们会存在那样的感觉呢?
其实是因为调度策略的原因。同步任务遇到就直接执行了,不会存在什么队列什么等待,所以我们根本不用考虑什么宏、微;也只有异步任务的时候,我们才需要去判断它是不是微任务,需不需要先执行。
我们来举个列子,假设我们写了A、B、C、D四个函数,只有if B的时候,才需要去判断C和D。
但我们不能说如果现在参数是A的话,C和D这两个函数就不存在了,只是我们当前执行状态下不用考虑C、D。
总结一下
同、异步任务和宏、微任务都是独立存在的,不存在什么上下级关系,只是同、异步的优先级更高。
微任务是宏任务派生出来的,所以是先执行宏任务,执行完成后再去把自己派发出来的微任务完成,才会去执行下一个宏任务。
说了这么多,可能还是有点迷糊,我们来看道题,细细体会一下
setTimeout(() => {
console.log('set out');
});
Promise.reslove().then(() => {
console.log('then');
});
console.log('log');
相信大家都知道打印顺序是 log,then,set out,那为什么呢?
首先按同、异去看,分别是:异步、异步、同步
那第一个执行的是 log,这没问题,再看两个异步问题,其中 setTimeout 是属于宏任务,而 Promise.then 属于微任务,这一整个代码块肯定是写在script标签中的(就算是写在js文件,也得用script标签引入),属于宏任务。
执行顺序是:
- 执行 script 宏任务(整个代码块)
- 遇到异步任务
setTimeout,判断属于宏任务,放到宏任务队列中,继续往下 - 遇到异步任务
Promise.then,判断属于微任务,放到微任务队列中,继续往下 - 遇到同步任务,直接进入主线程执行,输出第一个 log,并且所有任务已分发完毕
- 主线程已全部执行完毕,接收任务队列
- 任务队列中存在微任务队列,直接执行微任务,输出第二个 then
- 当前整个宏任务已全部执行完毕,开始执行下一个宏任务
setTimeout,输入第三个 set out
到这里,是不是感觉有个思路了,下次再遇见这类类似的题目,就知道要怎么思考了。可能有小伙伴又会说了,这题这么简单,是个人的都知道。
那好,我们来看一道真正的面试题
面试题
这是一道哔哩哔哩的面试题,具体哪年的就记不清楚了(这不重要),看题:
var date = new Date();
console.log(1, new Date() - date);
setTimeout(() => {
console.log(2, new Date() - date);
}, 500);
Promise.resolve().then(console.log(3, new Date() - date));
while(new Date() - date < 1000){}
console.log(4, new Date() - date);
是不是感觉有点难度了?问题不大,还是按照我们思路来(简化步骤)
- 执行同步任务,输出
1 0 - 遇到异步任务
setTimeout,注意!这里不是直接放入任务队列,而是交由 定时器触发引擎 去执行,因为设置了500ms的延时执行(不设置的话,默认是0),等 定时器触发引擎 执行完成后,才会交由 事件触发引擎 分配到任务队列 - 执行到
Promise.then时,由于then的参数不是函数,其中的内容被当做同步任务执行,输出3 0 - 执行同步任务
while,while循环的满足条件是等待1000ms,但在此期间setTimeout的500ms已执行完毕,其回调函数进入到宏任务队列中 - 执行同步任务,输出
4 1000 - 主线程已全部执行完毕,接收任务队列
- 微任务队列为空,执行下一个宏任务,输出
2 1000
最后我们的输出结果是 [1 0],[3 0],[4 1000],[2 1000]
结语
到这里,JS执行的过程相信大家基本上在脑海里都个画面了,遇到任务该如何做,怎么去分配,怎么去执行,都有一个明确的认知,这里就不多说了。
下次再遇到执行顺序的问题,按思路来就行。Promise 在其中是一个经常考的点,也是一个难点,这里由于篇幅限制,就不细说了,就简单提点几个注意事项
注意事项
Promise.resolve()的构造函数是同步任务,只有.then()才是异步的微任务Promise.then()或者.catch()的参数期望值是函数,传入非函数会导致值透传,被当成同步任务执行- Promise的状态一经改变,就不会再变,当构造函数中的
resolve或reject执行多次,只看第一次的执行结果即可,后面都是无效的 Promise.then()是能接收两个参数的,第一次是处理成功的回调,第二个是处理失败的回调,在某些特定情况下,你可以认为catch()方法是 then 第二个参数的简便写法- 当遇到
Promise.then时,如果当前的Promise还处于 pending 状态,我们并不能确定调用resolve还是reject时,只有等待Promise的状态确定后,再做处理。所以我们需要把我们的两种情况的处理逻辑做成callback放入Promise的回调数组内,当Promise状态翻转为resolve时,才将之前的Promise.then()推入微任务队列。
简而言之:只有当new Promise(callback)的回调被执行后并且状态改变,.then()才会被推入微任务队列。
以上观点均为个人浅显认知,若有不全或存在错误,欢迎各位大佬斧正、指点!