重学前端 --- 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)
重学前端 --- Promise里的代码为什么比setTimeout先执行?  
五、定时器   “任务队列” 中除了放置异步任务的事件,还可以放置定时事件,即指定某些事件在多少事件后执行   以 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
重学前端 --- Promise里的代码为什么比setTimeout先执行? 如果把上面代码中的第二个 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 

 

   

 

给TA打赏
共{{data.count}}人
人已打赏
随笔日记

Spring Boot系列(一) Spring Boot准备知识

2020-11-9 4:33:47

随笔日记

钛媒体创始人赵何娟:视频没反转 刘强东案里的一种偏见与五个问题

2020-11-9 4:33:49

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索