JavaScript系列——JavaScript同步、異步、回調執行順序之經典閉包setTimeout面試題分析

同步、異步、回調?傻傻分不清楚。html

你們注意了,教你們一道口訣:面試

同步優先、異步靠邊、回調墊底(讀起來不順)ajax

用公式表達就是:瀏覽器

同步 => 異步 => 回調網絡

這口訣有什麼用呢?用來對付面試的。閉包

有一道經典的面試題:併發

for (var i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log('i: ',i);
    }, 1000);
}

console.log(i);

//輸出
5
i:  5
i:  5
i:  5
i:  5
i:  5

這道題目你們都遇到過了吧,那麼爲何會輸出這個呢?記住咱們的口訣 同步 => 異步 => 回調異步

一、for循環和循環體外部的console是同步的,因此先執行for循環,再執行外部的console.log。(同步優先)async

二、for循環裏面有一個setTimeout回調,他是墊底的存在,只能最後執行。(回調墊底)函數

那麼,爲何咱們最早輸出的是5呢?

很是好理解,for循環先執行,可是不會給setTimeout傳參(回調墊底),等for循環執行完,就會給setTimeout傳參,而外部的console打印出5是由於for循環執行完成了。

知乎有大神講解過 80% 應聘者都不及格的 JS 面試題 ,就是以這個例子爲開頭的。可是沒有說爲何setTimeout是輸出5個5。

這裏涉及到JavaScript執行棧和消息隊列的概念,概念的詳細解釋能夠看阮老師的 JavaScript 運行機制詳解:再談Event Loop - 阮一峯的網絡日誌,或者看 併發模型與Event Loop

clipboard.png

《圖片來自於MDN官方》

我拿這個例子作一下講解,JavaScript單線程如何處理回調呢?JavaScript同步的代碼是在堆棧中順序執行的,而setTimeout回調會先放到消息隊列,for循環每執行一次,就會放一個setTimeout到消息隊列排隊等候,當同步的代碼執行完了,再去調用消息隊列的回調方法。

在這個經典例子中,也就是說,先執行for循環,按順序放了5個setTimeout回調到消息隊列,而後for循環結束,下面還有一個同步的console,執行完console以後,堆棧中已經沒有同步的代碼了,就去消息隊列找,發現找到了5個setTimeout,注意setTimeout是有順序的。

那麼,setTimeout既然在最後才執行,那麼他輸出的i又是什麼呢?答案就是5。。有人說不是廢話嗎?

如今告訴你們爲何setTimeout全都是5,JavaScript在把setTimeout放到消息隊列的過程當中,循環的i是不會及時保存進去的,至關於你寫了一個異步的方法,可是ajax的結果還沒返回,只能等到返回以後才能傳參到異步函數中。
在這裏也是同樣,for循環結束以後,由於i是用var定義的,因此var是全局變量(這裏沒有函數,若是有就是函數內部的變量),這個時候的i是5,從外部的console輸出結果就能夠知道。那麼當執行setTimeout的時候,因爲全局變量的i已是5了,因此傳入setTimeout中的每一個參數都是5。不少人都會覺得setTimeout裏面的i是for循環過程當中的i,這種理解是不對的。

===========================================分割線=========================================

看了上面的解釋,你是否是有點頭暈,沒事,繼續深刻講解。

咱們給第一個例子加一行代碼。

for (var i = 0; i < 5; ++i) {
    setTimeout(function() {
        console.log('2: ',i);
    }, 1000);
    console.log('1: ', i); //新加一行代碼
}

console.log(i);

//輸出
1:  0
1:  1
1:  2
1:  3
1:  4
5
2:  5
2:  5
2:  5
2:  5
2:  5

來,你們再跟着我一塊兒念一遍:同步 => 異步 => 回調 (強化記憶)

這個例子能夠很清楚的看到先執行for循環,for循環裏面的console是同步的,因此先輸出,for循環結束後,執行外部的console輸出5,最後再執行setTimeout回調 55555。。。

=====================================分割線============================================

這麼簡單,不夠帶勁是否是,那麼面試官會問,怎麼解決這個問題?

最簡單的固然是let語法啦。。

for (let i = 0; i < 5; ++i) {
    setTimeout(function() {
        console.log('2: ',i);
    }, 1000);
}

console.log(i);

//輸出
i is not defined
2:  0
2:  1
2:  2
2:  3
2:  4

咦,有同窗問,爲何外部的i報錯了呢?
又有同窗問,你這個口訣在這裏好像不適應啊?

let是ES6語法,ES5中的變量做用域是函數,而let語法的做用域是當前塊,在這裏就是for循環體。在這裏,let本質上就是造成了一個閉包。也就是下面這種寫法同樣的意思。若是面試官對你說用下面的這種方式,還有let的方式,你能夠嚴肅的告訴他:這就是一個意思!這也就是爲何有人說let是語法糖。

var loop = function (_i) {
    setTimeout(function() {
        console.log('2:', _i);
    }, 1000);
};

for (var _i = 0; _i < 5; _i++) {
    loop(_i);
}

console.log(i);

面試官總說閉包、閉包、閉包,什麼是閉包?後面再講。

寫成ES5的形式,你是否是發現就適合我說的口訣了?而用let的時候,你發現看不懂?那是由於你沒有真正瞭解ES6的語法原理。

咱們來分析一下,用了let做爲變量i的定義以後,for循環每執行一次,都會先給setTimeout傳參,準確的說是給loop傳參,loop造成了一個閉包,這樣就執行了5個loop,每一個loop傳的參數分別是0,1,2,3,4,而後loop裏面的setTimeout會進入消息隊列排隊等候。當外部的console執行完畢,由於for循環裏的i變成了一個新的變量 _i ,因此在外部的console.log(i)是不存在的。

如今能夠解釋閉包的概念了:當內部函數以某一種方式被任何一個外部函數做用域訪問時,一個閉包就產生了。

我知道你又要我解釋這句話了,loop(_i)是外部函數,setTimeout是內部函數,當setTimeout被loop的變量訪問的時候,就造成了一個閉包。(別說你又暈了?)

隨便舉個新的例子。

function t() {
    var a = 10;
    var b = function() {
        console.log(a);    
    }
    b();
}
t(); //輸出 10

跟我一塊兒念口訣:同步 => 異步 => 回調 (強化記憶)
先執行函數t,而後js就進入了t內部,定義了一個變量,而後執行函數b,進入b內部,而後打印a,這裏都是同步的代碼,沒什麼異議,那麼這裏怎麼解釋閉包:函數t是外部函數,函數b是內部函數,當函數b被函數t的變量訪問的時候,就造成了閉包。

========================================分割線==============================================

上面主要講了同步和回調執行順序的問題,接着我就舉一個包含同步、異步、回調的例子。

let a = new Promise(
  function(resolve, reject) {
    console.log(1)
    setTimeout(() => console.log(2), 0)
    console.log(3)
    console.log(4)
    resolve(true)
  }
)
a.then(v => {
  console.log(8)
})

let b = new Promise(
  function() {
    console.log(5)
    setTimeout(() => console.log(6), 0)
  }
)

console.log(7)

看到這個例子,千萬不要懼怕?,先讀一遍口訣:同步 => 異步 => 回調 (強化記憶)

一、看同步代碼:a變量是一個Promise,咱們知道Promise是異步的,是指他的then()和catch()方法,Promise自己仍是同步的,因此這裏先執行a變量內部的Promise同步代碼。(同步優先)

console.log(1)
    setTimeout(() => console.log(2), 0) //回調
    console.log(3)
    console.log(4)

二、Promise內部有4個console,第二個是一個setTimeout回調(回調墊底)。因此這裏先輸出1,3,4回調的方法丟到消息隊列中排隊等着。

三、接着執行resolve(true),進入then(),then是異步,下面還有同步沒執行完呢,因此then也滾去消息隊列排隊等候。(真可憐)(異步靠邊)
四、b變量也是一個Promise,和a同樣,執行內部的同步代碼,輸出5,setTimeout滾去消息隊列排隊等候。

五、最下面同步輸出7。

六、同步的代碼執行完了,JavaScript就跑去消息隊列呼叫異步的代碼:異步,出來執行了。這裏只有一個異步then,因此輸出8。

七、異步也over,輪到回調的孩子們:回調,出來執行了。這裏有2個回調在排隊,他們的時間都設置爲0,因此不受時間影響,只跟排隊前後順序有關。則先輸出a裏面的回調2,最後輸出b裏面的回調6。

八、最終輸出結果就是:一、三、四、五、七、八、二、6。

咱們還能夠稍微作一點修改,把a裏面Promise的 setTimeout(() => console.log(2), 0)改爲 setTimeout(() => console.log(2), 2),對,時間改爲了2ms,爲何不改爲1試試呢?1ms的話,瀏覽器都尚未反應過來呢。你改爲大於或等於2的數字就能看到2個setTimeout的輸出順序發生了變化。因此回調函數正常狀況下是在消息隊列順序執行的,可是使用setTimeout的時候,還須要注意時間的大小也會改變它的順序。

====================================分割線==================================================

口訣不必定是萬能的,只能做爲一個輔助,更重要的仍是要理解JavaScript的運行機制,才能對代碼執行順序有清晰的路線。

還有async/await等其餘異步的方案,不論是哪一種異步,基本都適用這個口訣,對於新手來講,能夠快速讀懂面試官出的js筆試題目。之後不再用懼怕作筆試題啦。

特殊狀況下不適應口訣的也很正常,JavaScript博大精深,不是一句話就能歸納出來的。

最後,在跟着我念一遍口訣:同步 => 異步 => 回調

若是文章對你有幫助,請點擊一下推薦。

相關文章
相關標籤/搜索