重学前端 --- Promise里的代码为什么比setTimeout先执行?
首先通过一段代码进入讨论的主题
var r = new Promise(function(resolve, reject){ console.log("a"); resolve() }); setTimeout(()=>console.log("d"), 0) r.then(() => console.log("c")); console.log("b") // a b c d
了解过 Promise 对象的都知道(如果还不了解,可以查看 Promise对象),Promise 新建后会立即执行,所以首先会输出a,这个没有问题。setTimeout 和 then 这两个回调函数会在本轮事件循环结束以后执行,所以第二个输出的是b,这个也没有问题,但是回过头来执行 setTimeout 和 then 方法时,setTimeout 的执行顺序明明先于 then 方法且延迟时间为0毫秒,为什么却后执行呢?是因为HTML5标准中规定setTimeout最小延迟时间不足4毫秒的仍然取值为4毫秒吗?显然不是,此处,就算把延迟时间从0改为4000毫秒,依然滞后于then 方法输出。接下来进入正题
提示:阮一峰老师的文章 《JavaScript 运行机制详解:再谈Event Loop》 是解开本次探讨答案的关键,建议仔细阅读
一、为什么Javascript是单线程?
JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
二、任务队列
单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备(很慢),挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。
所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)
- 同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;
- 异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)
1、所有同步任务都在主线程上执行,形成一个执行栈
2、主线程之外,还存在一个 “任务队列”。只要异步任务有了运行结果,就在 “任务队列” 中,放置一个事件
3、一旦 “执行栈” 中的所有同步任务执行完毕,系统就会读取 “任务队列”,看看里面有哪些事件,于是那些与事件相对应的异步任务结束等待状态,进入执行栈,开始执行
4、主线程不断重复第三步操作
只要主线程空了,就会去读取"任务队列",这就是JavaScript的运行机制。这个过程会不断重复
三、事件和回调函数
前面提到过,“任务队列” 其实是一个事件的队列,当IO设备完成一项任务时,就在 “任务队列” 中添加一个事件,主线程读取 “任务队列”,就是读取里面有哪些事件
“任务队列” 中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等)。只要指定过回调函数,这些事件发生时就会进入 “任务队列”,等待主线程读取
而所谓 “回调函数”,就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,其实就是执行对应的回调函数
四、事件循环
基于前面的分析,总结一下 “任务队列” 的特点:
1、“任务队列” 是一个先进先出的数据结构,排在前面的事件,优先被主线程读取
2、只要执行栈一清空,最早进入 “任务队列” 的事件会率先进入主线程
3、如果 “任务队列” 中存在定时器,主线程会先检查一下执行时间,某些事件只有到了规定的时间,才能进入主线程
主线程从 “任务队列” 中读取事件,这个过程是循环不断的,所以这种运行机制又称为事件循环(Event Loop)
五、定时器
“任务队列” 中除了放置异步任务的事件,还可以放置定时事件,即指定某些事件在多少事件后执行
以 setTimeout(fn, delay) 为例,它接受两个参数,第一个是回调函数,第二个是推迟执行的毫秒数
console.log(1); setTimeout(function(){console.log(2);},1000); console.log(3); // 1 3 2
上面的代码输出结果毫无悬念,因为 setTimeout() 将第二行代码推迟到1秒钟以后才执行,但是,将延迟时间设为0以后依然输出同样的结果。理论上延迟时间为0表示的是不延迟、立即执行
但是基于前面的介绍,JS 引擎在执行这段代码时,首先把第一行和第三行代码存入执行栈,把第二行代码存入 “任务队列”,只有当执行栈清空以后,主线程才会读取 “任务队列”,这里的 0毫秒实际上表示的意思是:执行栈清空以后,主线程立即读取存放在 “任务队列” 中的该段代码,所以输入的结果是 1 3 2
console.log(1); setTimeout(function(){console.log(2);}, 0); console.log(3); // 1 3 2
六、宏观任务(MacroTask)和 微观任务(MicroTask)
在重学前端系列文章中,winter老师也引入了 “宏观任务” 和 “微观任务” 的概念
- 宏观任务:宿主(我们)发起的任务
- 微观任务:Javascript引擎发起的任务
微观任务执行顺序始终先于宏观任务,并且每个宏观任务可以包含多个微观任务
(此处纯属个人理解:宏观任务保存在 “任务队列” 中,微观任务保存在 执行栈中,事件循环其实也就是不断执行宏观任务)
var r = new Promise(function(resolve, reject){ console.log("a"); resolve() }); setTimeout(()=>console.log("d"), 0) r.then(() => console.log("c")); console.log("b")
再回头来看看开头的一段代码,会不会豁然开朗了呢。JS 引擎首先会把Promise对象 和 console.log("b") 两个微观任务存入执行栈,把 setTimeout(宏观任务)存入 “任务队列”
所以在输出 a 和 b 以后并不会按照预期那样立即从 “任务队列” 中读取 setTimeout,因为 then方法是微观任务Promise对象的回调函数,先于 setTimeout 执行
如果对以上内容都没问题的话,可以再看一段示例代码
Promise.resolve().then(()=>{ console.log('1') setTimeout(()=>{ console.log('2') },0) }) setTimeout(()=>{ console.log('3') Promise.resolve().then(()=>{ console.log('4') }) },0)
在交流群中看到有的小伙伴还是不太清楚正确的执行顺序,基于前面的介绍,大致的分析过程及草图如下:
1(红色):JS 引擎会把微观任务Promise存入执行栈,把宏观任务setTimeout存入 “任务队列”
2(绿色):主线程率先运行执行栈中的代码,依次输入1,然后把绿框的setTimeout存入 “任务队列”
3(蓝色):执行栈清空以后,会率先读取 “任务队列” 中最早存入的setTimeout(红框的那个),并把这个定时器存入栈中,开始执行。这个定时器中的代码都是微观任务,所以可以一次性执行,依次输出3 和 4
4(紫色):重复第3步的操作,读取 “任务队列” 中最后存入的setTimeout(绿框的那个),输出2
所以最终的输出结果就是 1 3 4 2
如果把上面代码中的第二个 setTimeout 延迟时间从0改为3000,结果会稍有不同,按照上面的分析步骤来拆解应该也挺简单
Promise.resolve().then(()=>{ console.log('1') setTimeout(()=>{ console.log('2') },0) }) setTimeout(()=>{ console.log('3') Promise.resolve().then(()=>{ console.log('4') }) }, 3000) // 1 2 3 4
还有一段在知乎上挺热闹的代码,有人不解为什么不是输出 1 2 3 4 5,其实按照上面的分析步骤就完全可以解释这个问题
setTimeout(function(){console.log(4)},0); new Promise(function(resolve){ console.log(1) for( var i=0 ; i<10000 ; i++ ){ i==9999 && resolve() } console.log(2) }).then(function(){ console.log(5) }); console.log(3); // 1 2 3 5 4
另外一个会让人感到迷惑的地方就是 resolve回调函数内部的那几行代码,输出1以后接着跑1000次循环才调用resolve方法,其实resolve()的意思是把 Promise对象实例的状态从pending变成 fulfilled(即成功)
成功的回调就是对应的then方法。所以resolve() 后面的 console.log(2) 会先执行,因为 resolve() 回调函数是在本轮事件循环的末尾执行 (关于这部分内容,可以参考 Promise对象 一文)
同理,如果把代码中的 resolve() 去掉,也就是说 Promise 实例的状态一直保持在pending,就永远不会输出5了
setTimeout(function(){console.log(4)},0); new Promise(function(resolve){ console.log(1) for( var i=0 ; i<10000 ; i++ ){ // i==9999 && resolve() } console.log(2) }).then(function(){ console.log(5) }); console.log(3); // 1 2 3 4