无论是日常工作还是面试求职,经常会遇到这样的情况:给定几行代码,输出的结果。要想不被这样的问题困扰。就需要搞懂JavaScript的执行机制。
原因: JavaScript 是单线程的。单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。为了协调事件、用户交互、脚本、渲染、网络请求等,必须使用Event Loop。
JavaScript 为什么被设计成单线程的?
作为浏览器脚本语言,js的主要用途是与用户交互,以及操作DOM, 这就决定了它只能是单线程的,否则就会有很复杂的异步问题。比如,假定js同时有两个线程,一个线程在某个DOM节点上添加内容,而另一个线程删除这个DOM节点,这时浏览器要以哪个操作为准呢?所以,为了避免复杂性,js被设计为单线程。
当然,现如今人们也意识到,单线程在保证了执行顺序的同时也限制了javascript的效率,因此开发出了web worker技术。这项技术号称让javascript成为一门多线程语言。
然而,使用web worker技术开的多线程有着诸多限制,例如:所有新线程都受主线程的完全控制,不能独立执行。这意味着这些“线程” 实际上应属于主线程的子线程(由浏览器开辟)。另外,这些子线程并没有执行I/O操作的权限,只能为主线程分担一些诸如计算等任务。所以严格来讲这些线程并没有完整的功能,也因此这项技术并非改变了javascript语言的单线程本质。
先来通过一段代码来感受一下
setTimeout(function(){
console.log('定时器开始啦')
});
new Promise(function(resolve){
console.log('马上执行for循环啦');
for(var i = 0; i < 10000; i++){
i == 99 && resolve();
}
}).then(function(){
console.log('执行then函数啦')
});
console.log('代码执行结束');
结果:
马上执行for循环啦
代码执行结束
执行then函数啦
定时器开始啦
结果有没有超出你的想象呢? 面试过程中此类的问题出现频率很高,想要彻底解决这类问题,就必须搞懂js的运行机制了。
js的事件循环
既然js是单线程,那就像只有一个窗口的银行,客户需要排队一个一个办理业务,同理js任务也要一个一个顺序执行。如果一个任务耗时过长,那么后一个任务也必须等着。那么问题来了,假如我们想浏览新闻,但是新闻包含的超清图片加载很慢,难道我们的网页要一直卡着直到图片完全显示出来?因此聪明的程序员将任务分为两类:
用文字来描述上图中的内容:
上述过程会不断重复,也就是常说的Event Loop(事件循环)。
我们不禁要问了,那怎么知道主线程执行栈为空啊?js引擎存在monitoring process进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue那里检查是否有等待被调用的函数。
用一段简单的代码来说明执行的顺序:
let data = [];
$.ajax({
url:www.javascript.com,
data:data,
success:() => {
console.log('发送成功!');
}
})
console.log('代码执行结束');
上面这段简单的代码的执行会经过下边这几个步骤:
换一张图也许能帮助你更好的理解主线程的执行过程:
除了广义的同步任务和异步任务,我们对任务有更精细的定义:
不同类型的任务会进入对应的Event Queue。事件循环的顺序,决定js代码的执行顺序。进入整体代码(宏任务)后,开始第一次循环。接着执行所有的微任务。然后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。看下面这段代码:
setTimeout(function() {
console.log('setTimeout');
})
new Promise(function(resolve) {
console.log('promise');
}).then(function() {
console.log('then');
})
console.log('console');
//结果
promise
console
setTimeout
那么这段代码的执行顺序是这样的:
console.log('1');
setTimeout(function() {
console.log('2');
process.nextTick(function() {
console.log('3');
})
new Promise(function(resolve) {
console.log('4');
resolve();
}).then(function() {
console.log('5')
})
})
process.nextTick(function() {
console.log('6');
})
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8')
})
setTimeout(function() {
console.log('9');
process.nextTick(function() {
console.log('10');
})
new Promise(function(resolve) {
console.log('11');
resolve();
}).then(function() {
console.log('12')
})
})
// 结果
1 - 7 - 6 - 8 - 2 - 4 - 3 - 5 - 9 - 11 - 10 - 12
接下来看一下setTimeOut
setTimeout
用的地方多了,问题也出现了,有时候明明写的延时3秒,实际却5,6秒才执行函数,这又咋回事啊?来看一下这个例子:
setTimeout(() => {
task();
},3000)
console.log('执行console');
//结果为
执行console
task();
这是没有问题的,但是,当把代码稍微改动一下,例如
setTimeout(() => {
task()
},3000)
sleep(10000000);
这个时候你就会发现执行task()
的时间远远的超过3秒,说好的三秒呢???这个时候我们就需要重新理解setTimeout的含义了。在此之前先来看看上面代码的执行顺序:
sleep
函数,时间非常长,计时仍在继续说完代码的执行顺序,也就明白setTimeOut
这个函数的含义是在指定时间后,把要执行的任务加入到event queue中。经常会遇到setTimeout(fn,0)
的情况,这是立即执行吗???当然不是,setTimeout(fn,0)
的含义是,指定某个任务在主线程最早可得的空闲时间执行,意思就是不用再等多少秒了,只要主线程执行栈内的同步任务全部执行完成,栈为空就马上执行。
再来看一下setInterval()
上面说完了setTimeout
,当然不能错过它的孪生兄弟setInterval
。他俩差不多,只不过后者是循环的执行。对于执行顺序来说,setInterval会每隔指定的时间将注册的函数置入Event Queue,如果前面的任务耗时太久,那么同样需要等待。
唯一需要注意的一点是,对于setInterval(fn,ms)
来说,我们已经知道不是每过ms秒会执行一次fn,而是每过ms秒,会有fn进入Event Queue。一旦setInterval的回调函数fn执行时间超过了延迟时间ms,那么就完全看不出来有时间间隔了。这句话请读者仔细品味。
js的事件循环机制是其作为单线程语言能实现高效异步运行的核心基础。node出现后,js是运行环境就不再是单一的浏览器。同样在node环境中js也有对应的事件循环。
nodejs采用V8作为js的解析引擎。
先来感受一下事件循环在浏览器中和在node中的区别
setTimeout(()=>{
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(()=>{
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
浏览器中的运行结果:
timer1 - promise1 - timer2 - promise2
node环境的运行结果
timer1 - timer2 - promise1 - promise2
通过上一节的分析浏览器中的结果没有什么意外,那么Node环境的输出结果又是为什么捏?
Node.js采用V8作为js的解析引擎,而I/O处理方面使用自己设计的libuv
,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统的一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现。
根据Node.js官方介绍,每次事件循环都包含了6个阶段,对应到 libuv 源码中的实现,如下图所示:
接下来我们重点看timers,poll,check这三个阶段,因为日常开发中的绝大部分任务都是在这三个阶段中处理的。
timers阶段
timers 是事件循环的第一个阶段,Node 会去检查有无已过期的timer,如果有则把它的回调压入timer的任务队列中等待执行,事实上,Node 并不能保证timer在预设时间到了就会立即执行,因为Node对timer的过期检查不一定靠谱,它会受机器上其它运行程序影响,或者那个时间点主线程不空闲。比如下面的代码,setTimeout() 和 setImmediate() 的执行顺序是不确定的
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
但是把它们放到一个I/O回调里面,就一定是 setImmediate() 先执行,因为poll阶段后面就是check阶段。
poll阶段
poll 阶段主要有2个功能:
even loop将同步执行poll队列里的回调,直到队列为空或执行的回调达到系统上限(上限具体多少未详),接下来even loop会去检查有无预设的setImmediate(),分两种情况:
注意一个细节,没有setImmediate()会导致event loop阻塞在poll阶段,这样之前设置的timer岂不是执行不了了?所以咧,在poll阶段event loop会有一个检查机制,检查timer队列是否为空,如果timer队列非空,event loop就开始下一轮事件循环,即重新进入到timer阶段。
check阶段
setImmediate()的回调会被加入check队列中, 从event loop的阶段图可以知道,check阶段的执行顺序在poll阶段之后。
小结
现在,我们再来看Node.js 与浏览器的 Event Loop 差异:浏览器环境下,microtask 的任务队列是每个 macrotask 执行完之后执行。而在 Node.js 中,microtask 会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行 microtask 队列的任务。
那么现在再来看这段代码在node环境的执行:
setTimeout(()=>{
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(()=>{
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
再来看一个例子加深一下理解:
console.log('start')
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(() => {
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
Promise.resolve().then(function() {
console.log('promise3')
})
console.log('end')
结果
start - end - promise3 - timer1 - timer2 - promise1 - promise2
注意⚠️
setImmediate
设计在 poll 阶段完成时执行,即 check 阶段;setTimeout
设计在 poll 阶段为空闲时,且设定时间到达后执行,但它在 timer 阶段执行