重學前端 --- 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 

 

 
 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM