JavaScript的执行机制

414 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第2天,点击查看活动详情

JS 是单线程的

不同于 Java 等多线程语言,JavaScript 这门语言是单线程的,这与它的用途是相关的.大家都知道 JavaScript 诞生之初就是作为浏览器的脚本语言.所以它的主要用途就是与用户进行交互以及操作 DOM.假如有两个线程,其中一个线程想要添加一个节点,而另一个线程则想要删除一个节点.如果这两个线程同时运行,那么结果势必会导致混乱,所以 JavaScript 是一门单线程的语言.

同步和异步

JavaScript 虽然是单线程的,但是也是分 同步异步 的.像我们平时用的比较多的 ajax 请求, setTimeout 定时器等都是异步任务. 说起 同步异步,相信大家应该都是有所了解的.这俩应该算是编程中的比较基础的两个概念了.从抽象一点的角度说,同步就是在执行一个方法或函数之后,程序进入到一个阻塞状态,直到获取系统返回的结果之后,再执行之后的命令.而异步则是在执行完一个方法或函数后,程序是非阻塞的,继续执行下面的命令,等到系统返回消息的时候,再去做相对应的处理,也就是说多个相关事件不一定要等待前面一个事件完成再去做.从形象一点的角度说,举个生活的例子就是早上起床之后熬粥,在熬粥的时候,我啥也不干就等着它熬好,那就是同步.要是我不等它,自己去刷牙洗脸,最后粥熬好的时候我过来吃那就是异步.

为啥要有同步异步之分

那么为什么会有同步和异步呢?这是因为 JavaScript 是单线程的,那就表示它执行的任务需要排队.当前一个任务结束的时候,才会执行后一个任务.大家都知道 cpu 的处理速度比内存快上百倍,比硬盘更是快上百万倍.所以很多时候 cpu 不得不在那边等待内存或硬盘返回结果.这就很消耗时间了,我明明很早就把一个事情干完了,为啥还要等你那么久.于是我决定不等了,先干后面的事情,等你返回了结果后通知我,我再去处理刚刚挂起的任务.于是同步和异步就这么来了,其中同步任务是在主线程上执行,而异步任务则是进入任务队列.当任务队列通知主线程某个异步任务可以执行了(比如定时器的时间到了等),该任务才会进入主线程执行.主线程从任务队列中读取的这个过程会一直循环进行,这个过程就叫做事件循环(Event Loop).

任务队列

任务队列就是一个事件队列,我们前面提到的有些耗时的任务或一些异步任务,我们将它放到任务队列中.当满足某些条件时就可以从任务队列中取出去放到主线程中执行. 我们先来看下面的代码,类似的问题是有可能出现在一些面试题中的.问的就是打印出来的顺序是多少?

setTimeout(() => {
  console.log(1)
})
console.log(2)
new Promise((resolve, reject) => {
  console.log(3)
  resolve()
}).then(() => {
  console.log(4)
})
// 2 3 4 1

为什么是 2341 的顺序呢?我们来分析下,JS 中代码的执行是从上到下一行一行执行的.首先执行的是 setTimeout 这段代码,发现这是定时器任务,于是便把内部的具体执行内容 console.log(1) 先拿出来放到其他地方,准备待会儿再执行.继续执行到 console.log(2) 这句,于是先输出一个 2.继续执行,遇到了一个 Promise .注意在这个 Promise中 , console.log(3) 以及之后的 resolve() 这两句都是同步执行的,但是 then 里面的代码却是异步执行的.于是在输出了一个 3 之后,又把 console.log(4) 拿出来放到其他地方,准备晚点再去执行它.好了,现在我们已经把 console.log(1)console.log(4) 扔进了一个地方,那么为什么是先输出 4 然后再是 1 呢?这是因为虽然 1 和 4 都被我们扔进了一个地方,我们可以把这个地方理解为一个大房子.1 和 4 被扔进了不同的房间.其中 1 被扔进了一个叫做 宏任务队列 的房间.4 被扔进了另一个叫做 微任务队列 的房间.这俩房间都住着好些人,我们来简单看下都有哪些朋友.

微任务和宏任务

  • 微任务(micro-task): Promise ,process.nextTick
  • 宏任务(macro-task): script, setTimeout, setInterval

这里每次我们的一个宏任务执行完毕后,都要去微任务队列看看有没有任务需要执行.如果此时微任务队列中有任务,那就先执行微任务队列中的任务,要把微任务队列中的任务都清空.执行完毕后再执行宏任务队列中的下一个任务.所谓一图胜千言,各位老铁请看下图:

执行顺序

总结起来就是:不同类型的任务会进入到对应的事件队列(Event Queue)中.每次执行下一个宏任务之前先去微任务队列里面查看,直到把微任务队列清空后再去执行宏任务队列中的任务.

上面的微任务中我们提到了 process.nextTick ,下面我们来简单了解下.process.nextTick 是在当前执行栈的最后,下一次事件循环开始前触发,而setTimeout 则是在事件循环开始后. 将下面代码放到 node 运行环境,执行结果为先 2 后 1

process.nextTick(() => {
  console.log(1)
})
console.log(2)
// 2 1

我们在上面的代码之前再放置一个 setTimeout 定时器,打印输出结果

setTimeout(() => {
  console.log(3)
})
process.nextTick(() => {
  console.log(1)
})
console.log(2)
// 2 1 3

可以看到输出的结果为 2 1 3 ,这就是因为 setTimeout 是个宏任务, 而 nextTick 则是个微任务.所以先清空微任务队列,即先执行 process.nextTick 的回调.

运用微任务和宏任务来解题

到这里我们已经介绍了同步异步以及微任务队列和宏任务队列的相关内容了,我们再来看一看一道复杂一点的题目.

setTimeout(() => {
  console.log(1)
})

setTimeout(() => {
  new Promise((resolve, reject) => {
    console.log(2)
    resolve()
  }).then(() => {
    console.log(3)
  })
})

console.log(4)

new Promise((resolve, reject) => {
  console.log(5)
  resolve()
}).then(() => {
  console.log(6)
})

new Promise((resolve, reject) => {
  console.log(7)
  setTimeout(() => {
    console.log(8)
  })
  resolve()
}).then(() => {
  console.log(9)
})
// 4 5 7 6 9 1 2 3 8

我们按照上面我画的那个图的思路来解决下这道题目.为了少打几个字,我就把类似 console.log(1) 之类的写成 1 了.首先执行前面两个 setTimeout ,于是把 123 放到了宏任务队列中.执行到 4 的时候,先打印出一个 4.然后是两个Promise,先打印出 5,然后把 6 放到了微任务队列中.再之后打印出 7,把 8 放到宏任务中,然后就是 9 放到微任务中.此时已经打印出 457,并且微任务中有[6,9],宏任务中有[1,2,3,8].代码第一遍已经执行完毕,前面提到了整个script 脚本相当于一个宏任务.于是便去执行微任务,接着打印出 69.此时微任务已经清空,去执行宏任务.选取宏任务队列中的第一个任务,打印出 1 之后.回过头去看看微任务队列是否还有未执行的任务,现在已经没有了.于是便继续执行宏任务队列中的下一个任务即 2.打印出 2 之后,因为这是一个 Promise ,所以将then里面的 3 放到微任务队列,此次宏任务执行完毕.此时的微任务队列有[3],宏任务队列有[8].再去执行微任务队列,打印出 3.最后再次执行宏任务队列,打印出 8.经过上面这么一波分析,大家是不是已经明白了遇到这样的问题该如何解决了.