JavaScript到底是怎么执行的🔥

1,610 阅读9分钟

引言

先出道题,如果大家能答对,那可以关掉页面了。

以下这段代码的执行结果是什么?

setTimeout(function() {
    console.log('我是定时器')
},2000);
new Promise(function(resolve) {
    console.log('开始循环');
    for (var i = 0; i < 1000; i++) {
    	i == 999 && resolve();
    }
}).then(function() {
    console.log('循环结束了')
});
console.log('代码执行结束');
参考答案
开始循环
代码执行结束
循环结束了
我是定时器

一、单线程的JavaScript

单线程就是同一个时间只能做一件事。多线程就是同一个时间可以做很多事情。

JavaScript是单线程的。举个很简单的例子你就明白了,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,那浏览器要怎么显示,是不是乱套了。所以JavaScript只能是单线程的。

也许会有人说再HTML5中可以用new Worker(xxx.js)在JavaScript中创建多个线程。但是子线程完全受主线程控制,且不得操作DOM。所以JavaScript还是单线程的。

二、JavaScript中的同步任务和异步任务

单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。这就是JavaScript中的同步任务。

但是同步任务有个很大的缺点,如果前一个任务执行了很长时间还没结束,那下一个任务就不能执行,举个简单的例子,页面某个区域渲染过程中需要用Ajax去请求数据,如这个请求很长时间都请求不到数据,那下个任务就不能执行,也就说页面其他区域不能渲染。于是就有了JavaScript异步任务来解决这个缺点。

异步任务可以单独执行,不要等前一个任务结束后再执行。但是异步任务执行结束后就会在那边等待,直到线程里面没有任务了。才会喊异步任务的回调函数过来执行。

好了,直接开车上图。来讲一下JavaScript中的同步任务和异步任务是怎么执行的。

  • 同步和异步任务分别进入不同的执行"场所",同步的进入主线程,异步的进入事件列表(Event Table)并注册回调函数。
  • 当异步任务执行结束后,会将这个回调函数添加事件队列(Event Queue)。
  • 主线程内的任务执行完毕后,会去事件队列(Event Queue)中询问有没有要执行的任务,如果有,那就按先添加先执行的顺序进入任务执行栈,然后按之前步骤继续执行。
  • 上述过程会不断重复,也就是常说的事件循环(Event Loop)。

下面来个例子测试一下你是否懂了

setTimeout(function() {
	console.log(1);
}, 2000)
console.log(2);
setTimeout(function() {
	console.log(3);
}, 1000)
console.log(4);
参考答案
2,4,3,1

上面例子如果你回答正确,回到最初的那道题:

setTimeout(function() {
    console.log('我是定时器')
},1000);
new Promise(function(resolve) {
    console.log('开始循环');
    for (var i = 0; i < 1000; i++) {
    	i == 999 && resolve();
    }
}).then(function() {
    console.log('循环结束了')
});
console.log('代码执行结束');

按上面的内容分析一下。你可能会得到下面的答案

开始循环
代码执行结束
我是定时器
循环结束了

当然这个答案是错误,这时你是不是开始认为上面的内容是错的。

别着急,其实JavaScript异步任务还有宏任务和微任务的区分,下面就来介绍。

三、JavaScript中的宏任务和微任务

  • 宏任务(macro-task):整体代码script、setTimeout、setInterval、setImmediate
  • 微任务(micro-task):Promise、process.nextTick

宏任务和微任务是对JavaScript异步任务再次细分。异步事件队列分为宏任务事件队列和微任务事件队列。

直接开车上图。来说明JavaScript中的宏任务和微任务是怎么执行的。

  • 同步和异步任务分别进入不同的执行"场所",同步的进入主线程,异步的进入事件列表并注册回调函数。
  • 当异步任务执行结束后,判断该异步任务是宏任务还是微任务,将宏任务的回调函数添加宏任务事件队列,将微任务的回调函数添加到微任务事件队列。
  • 主线程内的任务执行完毕后。
    • 先去微任务事件队列中询问有没有要执行的任务,如果有,那就按先添加先执行的顺序进入任务执行栈。
    • 如果没有,再去宏任务事件队列中询问有没有要执行的任务。如果有,那就按先添加先执行的顺序进入任务执行栈。
    • 如果没有,那任务都执行完毕。
  • 上述过程会不断重复,也就是常说的事件循环(Event Loop)。

我们按上面的流程来分析一下最初的那道题:

setTimeout(function() {
    console.log('我是定时器')
},1000);
new Promise(function(resolve) {
    console.log('开始循环');
    for (var i = 0; i < 1000; i++) {
    	i == 999 && resolve();
    }
}).then(function() {
    console.log('循环结束了')
});
console.log('代码执行结束');
  • 整体script作为第一个宏任务进入主线程。

  • 遇到setTimeout为异步任务,任务执行结束后,判断其为宏任务,故将回调函数添加到宏任务事件队列,我们将它记为setTimeout

  • 遇到new Promise为异步任务,new Promise直接执行,输出开始循环,任务执行结束后,判断其为微任务,故将then 方法绑 定的回调函数添加到微任务事件队列,我们将它记为then

  • 遇到console.log('代码执行结束'),为同步任务,直接执行,输出代码执行结束

  • 任务都执行完毕,询问微任务事件队列有没有要执行的任务,有,为then,执行,输出循环结束了

  • 微任务队列都执行完毕了,询问宏任务事件队列有没有要执行的任务,有,为setTimeout,执行,输出我是定时器

    宏任务Event Queue微任务Event Queue
    setTimeoutthen

最终输出结果为

开始循环
代码执行结束
循环结束了
我是定时器

和正确答案一对没错。看来上面的JavaScript执行机制是没错的。下面我们再看一个复杂的例子,一起来彻底掌握JavaScript执行机制。

四、来个例子小结一下

console.log('1');
setTimeout(function() {
    console.log('2');
    new Promise(function(resolve) {
        console.log('3');
        resolve();
    }).then(function() {
        console.log('4')
    })
    setTimeout(function(){
        console.log('5')
    })
})
new Promise(function(resolve) {
    console.log('6');
    resolve();
}).then(function() {
    console.log('7')
})

setTimeout(function() {
    console.log('8');
    new Promise(function(resolve) {
        console.log('9');
        resolve();
    }).then(function() {
        console.log('10')
    })
    setTimeout(function(){
        console.log('11')
    })
})
  • 整体script作为第一个宏任务进入主线程。
  • 遇到console.log(1),为同步任务,直接执行,输出1
  • 遇到setTimeout为异步任务,任务执行结束后,判断其为宏任务,故将回调函数添加到宏任务事件队列,我们将它记为setTimeout1
  • 遇到new Promise为异步任务,new Promise直接执行,输出6,任务执行结束后,判断其为微任务,故将then 方法绑 定的回调函数添加到微任务事件队列,我们将它记为then1
  • 遇到setTimeout为异步任务,任务执行结束后,判断其为宏任务,故将回调函数添加到宏任务事件队列,我们将它记为setTimeout2
宏任务Event Queue微任务Event Queue
setTimeout1then1
setTimeout2
  • 上表是第一轮事件循环宏任务结束时各Event Queue的情况,此时已经输出了16
  • 询问微任务事件队列有没有要执行的任务,有,为then1,执行,输出7
  • 微任务队列都执行完毕了,询问宏任务事件队列有没有要执行的任务,有,为setTimeout1setTimeout2
  • 执行setTimeout1,首先遇见console.log(2),直接执行,输出2
  • 遇到new Promise为异步任务,new Promise直接执行,输出3,任务执行结束后,判断其为微任务,故将then 方法绑 定的回调函数添加到微任务事件队列,我们将它记为then2
  • 遇到setTimeout为异步任务,任务执行结束后,判断其为宏任务,故将回调函数添加到宏任务事件队列,我们将它记为setTimeout3
宏任务Event Queue微任务Event Queue
setTimeout2then2
setTimeout3
  • 上表是第二轮事件循环宏任务结束时各Event Queue的情况,此时已经输出了723
  • 询问微任务事件队列有没有要执行的任务,有,为then2,执行,输出4
  • 微任务队列都执行完毕了,询问宏任务事件队列有没有要执行的任务,有,为setTimeout2setTimeout3
  • 执行setTimeout2,首先遇见console.log(8),直接执行,输出8
  • 遇到new Promise为异步任务,new Promise直接执行,输出9,任务执行结束后,判断其为微任务,故将then 方法绑 定的回调函数添加到微任务事件队列,我们将它记为then3
  • 遇到setTimeout为异步任务,任务执行结束后,判断其为宏任务,故将回调函数添加到宏任务事件队列,我们将它记为setTimeout4
宏任务Event Queue微任务Event Queue
setTimeout3then3
setTimeout4
  • 上表是第三轮事件循环宏任务结束时各Event Queue的情况,此时已经输出了489
  • 询问微任务事件队列有没有要执行的任务,有,为then3,执行,输出10
  • 微任务队列都执行完毕了,询问宏任务事件队列有没有要执行的任务,有,为setTimeout3setTimeout4
  • 执行setTimeout3,首先遇见console.log(5),直接执行,输出5
宏任务Event Queue微任务Event Queue
setTimeout4
  • 上表是第四三轮事件循环宏任务结束时各Event Queue的情况,此时已经输出了105
  • 询问微任务事件队列有没有要执行的任务,没有。
  • 微任务队列都执行完毕了,询问宏任务事件队列有没有要执行的任务,有,为setTimeout4
  • 执行setTimeout2,首先遇见console.log(11),直接执行,输出11
宏任务Event Queue微任务Event Queue
  • 上表是第五轮事件循环宏任务结束时各Event Queue的情况,此时已经输出了11
  • 询问微任务事件队列有没有要执行的任务,没有。
  • 微任务队列都执行完毕了,询问宏任务事件队列有没有要执行的任务,没有。
  • 任务都执行完毕。
  • 最终输出1672348910511

在chrome上打印输出,对比没错。

如果把

setTimeout(function(){
    console.log('5')
})

改成

setTimeout(function(){
    console.log('5')
},2000)

结果又会怎么样,大家按上面推理一下。给大家提个醒,异步任务是执行完成后在添加到事件队列中。