重學前端 --- Promise裏的代碼爲何比setTimeout先執行?

首先經過一段代碼進入討論的主題html

   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》 是解開本次探討答案的關鍵,建議仔細閱讀瀏覽器

 

1、爲何Javascript是單線程?數據結構

JavaScript的單線程,與它的用途有關。做爲瀏覽器腳本語言,JavaScript的主要用途是與用戶互動,以及操做DOM。這決定了它只能是單線程,不然會帶來很複雜的同步問題。好比,假定JavaScript同時有兩個線程,一個線程在某個DOM節點上添加內容,另外一個線程刪除了這個節點,這時瀏覽器應該以哪一個線程爲準?
 
因此,爲了不復雜性,從一誕生,JavaScript就是單線程,這已經成了這門語言的核心特徵,未來也不會改變。
 
2、任務隊列
 
單線程就意味着,全部任務須要排隊,前一個任務結束,纔會執行後一個任務。若是前一個任務耗時很長,後一個任務就不得不一直等着。

JavaScript語言的設計者意識到,這時主線程徹底能夠無論IO設備(很慢),掛起處於等待中的任務,先運行排在後面的任務。等到IO設備返回告終果,再回過頭,把掛起的任務繼續執行下去。

全部任務能夠分紅兩種,一種是同步任務(synchronous),另外一種是異步任務(asynchronous)
- 同步任務指的是,在主線程上排隊執行的任務,只有前一個任務執行完畢,才能執行後一個任務;
- 異步任務指的是,不進入主線程、而進入"任務隊列"(task queue)的任務,只有"任務隊列"通知主線程,某個異步任務能夠執行了,該任務纔會進入主線程執行。
 
具體來講,異步執行的運行機制以下。(同步執行也是如此,由於它能夠被視爲沒有異步任務的異步執行。)
 
一、全部同步任務都在主線程上執行,造成一個執行棧
二、主線程以外,還存在一個 「任務隊列」。只要異步任務有了運行結果,就在 「任務隊列」 中,放置一個事件
三、一旦 「執行棧」 中的全部同步任務執行完畢,系統就會讀取 「任務隊列」,看看裏面有哪些事件,因而那些與事件相對應的異步任務結束等待狀態,進入執行棧,開始執行
四、主線程不斷重複第三步操做
 
只要主線程空了,就會去讀取"任務隊列",這就是JavaScript的運行機制。這個過程會不斷重複
 
3、事件和回調函數
 
前面提到過,「任務隊列」 實際上是一個事件的隊列,當IO設備完成一項任務時,就在 「任務隊列」 中添加一個事件,主線程讀取 「任務隊列」,就是讀取裏面有哪些事件
 
「任務隊列」 中的事件,除了IO設備的事件之外,還包括一些用戶產生的事件(好比鼠標點擊、頁面滾動等)。只要指定過回調函數,這些事件發生時就會進入 「任務隊列」,等待主線程讀取
 
而所謂 「回調函數」,就是那些會被主線程掛起來的代碼。異步任務必須指定回調函數,當主線程開始執行異步任務,其實就是執行對應的回調函數
 
4、事件循環
 
基於前面的分析,總結一下 「任務隊列」 的特色:
 
一、「任務隊列」 是一個先進先出的數據結構,排在前面的事件,優先被主線程讀取
二、只要執行棧一清空,最先進入 「任務隊列」 的事件會率先進入主線程
三、若是 「任務隊列」 中存在定時器,主線程會先檢查一下執行時間,某些事件只有到了規定的時間,才能進入主線程
 
主線程從 「任務隊列」 中讀取事件,這個過程是循環不斷的,因此這種運行機制又稱爲事件循環(Event Loop)
 
5、定時器
 
「任務隊列」 中除了放置異步任務的事件,還能夠放置定時事件,即指定某些事件在多少事件後執行
 
以 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

 

6、宏觀任務(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 
相關文章
相關標籤/搜索