JS执行原理详解(同步、异步、宏任务、微任务)

261 阅读16分钟

JS执行原理详解(同步、异步、宏任务、微任务)

提到 promise 我相信很多小伙伴都不陌生,自 ES6 发布后,被列为正式规范。也是其中最重要的特性之一,而且也是我们平常解决异步、回调等问题的最优解。
不仅如此,相信不少小伙伴面试时,没少遇到询问执行顺序、打印顺序的题了吧?相信最初都被折磨的不清,后来可能通过各种途径了解到了答题技巧。但,有没有好奇过这技巧是怎么来的呢?

我们这次不为别的,就来深挖一下这技巧背后所隐藏着的秘密!

什么是异步?

要了解原理,首先我们要先知道,什么是异步。
我们知道同步执行是一行一行从上往下执行代码,从字面上来讲,这个 “异” 好像与 “同” 相对,那是不是异步就是一起执行呢?就比如我 “一边打游戏,一边撸代码”。我游戏通关了,代码也写完了,这就是异步?
不对不对!要知道,JavaScript可是单线程的语言!假设代码执行是一条流水线,那么不管同步还是异步,都是在这一条流水线上工作,唯一的差别就是 执行顺序的不同

最常见的异步方法,就是 setTimeoutsetInterval 这两个定时请求了。我们来看下代码

//简单的异步执行
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引擎

定时器触发引擎

对异步定时器处理与执行 - setTimeoutsetInterval 接收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,这个过程就是出栈。这整个过程就是执行堆栈,也是栈的特点 先入后出

这样说可能记忆不深,我们来放浏览器执行一下。

image.png 看这张图,会发现先出现的是Error,而Error呢,是从fun2出来的,fun2又是从fun1出来的,fun1又是从run出来的。但我们的执行顺序呢,是先执行run,再执行fun1,最后才是fun2里的Error。

就好比如我们吃桶装薯片一样,我拿出来吃的,永远是最上面的,最先放进去的,永远是最后才拿出来吃的。(不接受某些人强行吃底下薯片的操作)

上面那张图可能有部分小伙伴不太懂,我们换个更浅显的

222.png 代码一行一行执行,当遇到 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 等

执行原则:宏任务的优先级永远大于微任务(有微则微,无微则宏)

这执行顺序怎么理解呢,就是:宏任务优先执行,宏任务中有包含微任务的,就执行微任务,执行完成或没有微任务的,再执行下一个宏任务。

我们来看张图加深一下印象

image.png

同步和异步

同步和异步,相信各位小伙伴都知道,上面也着重解释了一下,这里就不细说了,接下来重点讲一讲任务的执行机制(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(事件循环)

全都是理论性的文字,可能记忆不深,我们来看张图加深一下记忆

image.png

这样是否就感觉清楚多了^_^

可能有小伙伴就会问了,那之前的宏任务,微任务又是扮演的什么角色?好像没参与进来呀!

的确,毕竟我们之前都是从不同角度来看待问题的,肯定是有些片面的。接下来我们要把这两个角度结合起来,看一看它们之间的联系。

同、异、宏、微之间的关系

相信大多数小伙伴都有一种感觉:任务分为同步、异步任务,然后异步任务再分为宏、微任务。
实则不然,当然啦也不算错,只是理解的片面了一点

同步和异步是从宏任务的维度通过执行方式维度,区分了任务的执行顺序。

那为什么我们会存在那样的感觉呢?

其实是因为调度策略的原因。同步任务遇到就直接执行了,不会存在什么队列什么等待,所以我们根本不用考虑什么宏、微;也只有异步任务的时候,我们才需要去判断它是不是微任务,需不需要先执行。

我们来举个列子,假设我们写了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标签引入),属于宏任务。

执行顺序是:

  1. 执行 script 宏任务(整个代码块)
  2. 遇到异步任务 setTimeout ,判断属于宏任务,放到宏任务队列中,继续往下
  3. 遇到异步任务 Promise.then,判断属于微任务,放到微任务队列中,继续往下
  4. 遇到同步任务,直接进入主线程执行,输出第一个 log,并且所有任务已分发完毕
  5. 主线程已全部执行完毕,接收任务队列
  6. 任务队列中存在微任务队列,直接执行微任务,输出第二个 then
  7. 当前整个宏任务已全部执行完毕,开始执行下一个宏任务 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. 执行同步任务,输出 1 0
  2. 遇到异步任务 setTimeout ,注意!这里不是直接放入任务队列,而是交由 定时器触发引擎 去执行,因为设置了500ms的延时执行(不设置的话,默认是0),等 定时器触发引擎 执行完成后,才会交由 事件触发引擎 分配到任务队列
  3. 执行到 Promise.then 时,由于 then 的参数不是函数,其中的内容被当做同步任务执行,输出 3 0
  4. 执行同步任务 while ,while循环的满足条件是等待1000ms,但在此期间 setTimeout 的500ms已执行完毕,其回调函数进入到宏任务队列中
  5. 执行同步任务,输出 4 1000
  6. 主线程已全部执行完毕,接收任务队列
  7. 微任务队列为空,执行下一个宏任务,输出 2 1000

最后我们的输出结果是 [1 0],[3 0],[4 1000],[2 1000]

结语

到这里,JS执行的过程相信大家基本上在脑海里都个画面了,遇到任务该如何做,怎么去分配,怎么去执行,都有一个明确的认知,这里就不多说了。

下次再遇到执行顺序的问题,按思路来就行。Promise 在其中是一个经常考的点,也是一个难点,这里由于篇幅限制,就不细说了,就简单提点几个注意事项

注意事项

  1. Promise.resolve() 的构造函数是同步任务,只有 .then() 才是异步的微任务
  2. Promise.then() 或者 .catch() 的参数期望值是函数,传入非函数会导致值透传,被当成同步任务执行
  3. Promise的状态一经改变,就不会再变,当构造函数中的 resolvereject 执行多次,只看第一次的执行结果即可,后面都是无效的
  4. Promise.then() 是能接收两个参数的,第一次是处理成功的回调,第二个是处理失败的回调,在某些特定情况下,你可以认为 catch() 方法是 then 第二个参数的简便写法
  5. 当遇到 Promise.then 时,如果当前的 Promise 还处于 pending 状态,我们并不能确定调用 resolve 还是 reject 时,只有等待 Promise 的状态确定后,再做处理。所以我们需要把我们的两种情况的处理逻辑做成 callback 放入 Promise 的回调数组内,当 Promise 状态翻转为 resolve 时,才将之前的 Promise.then() 推入微任务队列。
    简而言之:只有当 new Promise(callback) 的回调被执行后并且状态改变,.then() 才会被推入微任务队列。

以上观点均为个人浅显认知,若有不全或存在错误,欢迎各位大佬斧正、指点!