Tip:個人博客主要在CSDN:CSDN博客 ,文章內容有修改的話也在CSDN上,感興趣的能夠去CSDN上關注我~javascript
JS中的事件循環原理以及異步執行過程這些知識點對新手來講可能有點難,可是是必須邁過的坎,逃避是解決不了問題的,本篇文章旨在幫你完全搞懂它們。html
說明:本篇文章主要是基於瀏覽器環境,Node環境沒有研究過暫時不討論。文章的內容也是博採衆長以及結合本身的理解完成的,相關參考文獻文章末尾也會給出,如有侵權請告知整改,或有描述不正確的也歡迎提醒。文章會有點長,耐心看完哦~java
不廢話,讓咱們從簡單到複雜,步步深刻,去享受知識的盛宴~git
咱們都知道JS是單線程執行的(緣由:咱們不想並行地操做DOM,DOM樹不是線程安全的,若是多線程,那會形成衝突),one thread --> one call stack --> one thing at a time
,也就是說,它只有一個執行棧(call stack),在同一時刻,JS引擎只能作一件事(JS在執行時有一個很是重要的特性:run to complete
,只要運行就直到完成)。看到 「JS是單線程的」這句話的時候,不知道你有沒有這樣的疑惑:既然JS是單線程的,那麼我在網頁向後端請求數據的時候,我怎麼還能夠操做頁面:我能夠滾動頁面,我也能夠點擊按鈕,這不是跟JS是單線程的衝突嗎?這個問題困擾了我很久,很大的一個緣由是:我覺得瀏覽器單單只是由一個JS引擎構成的。以下圖(我覺得的瀏覽器構造,這裏以谷歌瀏覽器chrome爲例): github
function foo() {
bar()
console.log('foo')
}
function bar() {
baz()
console.log('bar')
}
function baz() {
console.log('baz')
}
foo()
複製代碼
咱們定義了foo、bar、baz三個函數,而後調用foo函數,控制檯輸出的結果爲:web
baz
bar
foo
複製代碼
執行過程以下:chrome
console.log('baz')
,執行,在控制檯打印:baz,而後baz函數執行完畢彈出執行棧。baz()
語句已經執行完,接着執行下一條語句(console.log('bar')
),在控制檯打印:bar,而後bar函數執行完畢彈出執行棧。bar()
語句已經執行完,接着執行下一條語句(console.log('foo')
),在控制檯打印:foo,而後foo函數執行完畢彈出執行棧。仍是圖直觀點,以上步驟對應的執行流程圖以下: segmentfault
非動圖: 可是,若是咱們代碼中有異步事件該怎麼辦?咱們改變一下代碼1,代碼2以下:後端
function foo() {
bar()
console.log('foo')
}
function bar() {
baz()
console.log('bar')
}
function baz() {
setTimeout(() => {
console.log('setTimeout: 2s')
}, 2000)
console.log('baz')
}
foo()
複製代碼
其餘都不變,就在baz函數中增長了一個setTimeout函數。根據1中的假設,瀏覽器只由一個JS引擎構成的話,那麼全部的代碼必然同步執行(由於JS執行是單線程的,因此當前棧頂函數無論執行時間須要多久,執行棧中該函數下面的其餘函數必須等它執行完彈出後才能執行(這就是代碼被阻塞的意思)),執行到baz函數體中的setTimeout時應該等2秒,在控制檯中輸出setTimeout: 2s
,而後再輸出:baz
。因此咱們指望的輸出順序應該是:setTimeout: 2s -> baz -> bar -> foo(這是錯的)。api
瀏覽器若是真這樣設計的話,確定是有問題的!!! 遇到AJAX請求、setTimeout等比較耗時的操做時,咱們頁面須要長時間等待,就被阻塞住啥也幹不了,出現了頁面「假死」,這樣絕對不是咱們想要的結果。
實際固然並不是我覺得的那樣,這裏先重點提醒一下:JS是單線程的,這一點也沒錯,可是瀏覽器中並不只僅只是由一個JS引擎構成,它還包括其餘的一些線程來處理別的事情。以下圖(此圖參考了Philip Roberts的演講:《Help, I'm stuck in an event-loop》(YouTube版),被牆的能夠看這:《Help, I'm stuck in an event-loop》(bilibili版),這視頻推薦你們觀看):
瀏覽器除了 JS引擎(JS執行線程,後面咱們只關注JS引擎中的執行棧)之外,還有 Web APIs(瀏覽器提供的接口,這是在JS引擎之外的)線程、 GUI渲染線程等(以下表)。JS引擎在執行過程當中,若是遇到相關的事件(DOM操做、鼠標點擊事件、滾輪事件、AJAX請求、setTimeout等),並不會所以阻塞,它會將這些事件移交給Web APIs線程處理,而本身則接着往下執行。Web APIs(這裏其實有一個event table,用於記錄各類事件)則會按照必定的規則將這些事件放入一個任務隊列(callback queue,也叫 task queue, HTML標準定義中,任務隊列的數據結構其實不是隊列,而是Set(集合),好比,當前執行棧正在執行,即便有一個定時器回調已經在任務隊列中等待,此時發生了一個鼠標點擊事件,那麼該點擊事件回調也會添加到任務隊列中,此後執行棧變爲空,JS引擎是會先取鼠標點擊事件的回調執行,而不是先添加到任務隊列中的定時器回調。即任務隊列中是由一個個集構成的,各個集的執行前後是肯定好的,按集的優先級取回調執行,集內是同一類型的回調纔是按照先進先出的隊列模式)中,當JS執行棧中的代碼執行完畢之後,它就會去任務隊列中獲取一個事件回調放入執行棧中執行,而後如此往復,這就是所謂的 事件循環機制。線程名 | 做用 |
---|---|
JS引擎線程 | 也稱爲JS內核,負責處理JavaScript腳本。(例如V8引擎) ①JS引擎線程負責解析JS腳本,運行代碼。 ②JS引擎一直等待着任務隊列中的任務的到來,而後加以處理。 ③一個Tab頁(renderer進程)中不管何時都只有一個JS線程運行JS程序。 |
事件觸發線程 | 歸屬於渲染進程而不是JS引擎,用來控制事件循環 ①當JS引擎執行代碼塊如setTimeout時(也可來自瀏覽器內核的其餘線程,如鼠標點擊、Ajax異步請求等),會將對應任務添加到事件線程中。 ②當對應的事件符合觸發條件被觸發時,該線程會把事件添加到待處理隊列的隊尾,等待JS引擎的處理。 注意:因爲JS的單線程關係,因此這些待處理隊列中的事件都是排隊等待JS引擎處理,JS引擎空閒時纔會執行。 |
定時觸發器線程 | setInterval和setTimeout所在的線程 ①瀏覽器定時計數器並非由JS引擎計數的。 ②JS引擎時單線程的,若是處於阻塞線程狀態就會影響計時的準確,所以,經過單獨的線程來計時並觸發定時。 ③計時完畢後,添加到事件隊列中,等待JS引擎空閒後執行。 注意:W3C在HTML標準中規定,規定要求setTimeout中低於4ms的時間間隔算爲4ms。 |
異步http請求線程 | XMLHttpRequest在鏈接後經過瀏覽器新開一個線程請求 將檢測到狀態變動時,若是設置有回調函數,異步線程就產生狀態變動事件,將這個回調放入事件隊列中,再由JS引擎執行。 |
GUI渲染線程 | 負責渲染瀏覽器界面,包括: ①解析HTML、CSS,構建DOM樹和RenderObject樹,佈局和繪製等。 ②重繪(Repaint)以及迴流(Reflow)處理。 |
這裏讓咱們對事件循環先來作個小總結:
讓咱們來看看真正的瀏覽器中執行代碼1是什麼個流程吧! TIP:這裏的流程示例網站也是Philip Roberts的演講中提到的(是他本人寫的),能夠本身去嘗試嘗試:傳送門
在 代碼1中並無出現異步事件,也就不會調用到Web API線程,因此動圖中的Web API和任務隊列一直爲空。 此次,讓咱們運行一下有異步事件的 代碼2看看什麼效果: 能夠看到,當JS執行棧執行到baz中的setTimeout時,執行棧將該事件推送給Web API處理( Web API開始計時,而不是JS引擎來計時),本身則不被阻塞繼續執行,當JS執行棧爲空時再去任務隊列中獲取事件執行。因此代碼2的正確運行結果打印出來的順序應該是: baz -> bar -> foo -> setTimeout: 2s。 細心的小夥伴可能有發現Web API在計時器時間到達後將匿名回調函數添加到任務隊列中了,雖然定時器時間已到,但它目前並不能執行!!!由於JS的執行棧此時並不是空,必需要等到當前執行棧爲空後纔有機會被召回到執行棧執行。由此,咱們能夠得出一個結論: setTimeout設置的時間其實只是最小延遲時間,而並非確切的等待時間。(當主線程的任務耗時比較長的時候,等待時間將會變得更長)相信有了以上的鋪墊以後,你對瀏覽器中JS的執行流程有點感受了,讓咱們趁熱打鐵,進一步探討事件循環和異步吧~
如今讓咱們試試0秒延時的setTimeout執行會如何,按道理來講0秒延遲就是當即執行,那麼控制檯打印結果應該爲:setTimeout: 0s -> foo,事實如此嗎?
function foo() {
console.log('foo');
}
setTimeout(function() {
console.log('setTimeout: 0s');
}, 0);
foo();
複製代碼
實際控制檯打印結果的順序爲:foo -> setTimeout: 0s,來看看實際代碼執行的過程:
能夠看到,即便setTimeout的延時設置爲0(實際上 最小延時 >=4ms,參考 這),JS執行棧也將該延時事件發放給Web API處理,Web API再將事件添加到任務隊列中,等JS執行棧爲空時,該延時事件再壓入執行棧中執行。由此咱們能夠得出一個結論: JS執行棧只要遇到異步函數,則無腦推給Web APIs處理。與許多其餘語言不一樣,JS永不阻塞(也存在一些遺留的意外:如 alert 或者同步 XHR)。 處理 I/O 一般經過 事件和 回調來執行,因此當一個應用正等待一個 IndexedDB 查詢返回或者一個 XHR 請求返回時,它仍然能夠處理其它事情,好比用戶輸入。如今是時候再深刻一步了,咱們ES6中新增的promise已經火燒眉毛地亮相了!(本篇文章不討論Promise相關的知識點,若是你對Promise不瞭解的話,建議先去看看相關知識點)。 其實以上的瀏覽器模型是ES5標準的,ES6+標準中的任務隊列在此基礎上新增了一種,變成了以下兩種:
setTimeout(func)
便可將func
函數添加到宏任務隊列中(使用場景:將計算耗時長的任務切分紅小塊,以便於瀏覽器有空處理用戶事件,以及顯示耗時進度)。queueMicrotask(func)
能夠將func
函數添加到微任務隊列中。那麼,如今的事件循環模型就變成了以下的樣子:
事件循環的處理流程變成了以下:一圖勝千言,畫個流程圖更加清晰,幫助記憶:
排一下前後順序: 執行棧 --> 微任務 --> 渲染 --> 下一個宏任務。先來個只有Promise的例子熱熱身:
function foo() {
console.log('foo')
}
console.log('global start')
new Promise((resolve) => {
console.log('promise')
resolve()
}).then(() => {
console.log('promise then')
})
foo()
console.log('global end')
複製代碼
控制檯輸出的結果爲:
global start
promise
foo
global end
promise then
複製代碼
代碼執行的解釋:
console.log('global start')
語句,打印出:global start。new Promise(....)
,執行之(這裏說明一點:在使用new關鍵字來建立Promise對象時,傳遞給Promise的函數稱爲executor,當promise被建立的時候executor函數會自動執行,而then裏面的東西纔是異步執行的部分),Promise參數中的匿名函數與主線程同步執行,執行console.log('promise')
打印出:promise。在執行resolve()
以後Promise狀態變爲resolved,再繼續執行then(...)
,遇到then則將其提交給Web API處理,Web API將其添加到微任務隊列(注意:此時微任務隊列中已有一個Promise事件待處理)。foo()
,執行foo函數,打印出:foo。console.log('global end')
,執行後打印出:global end。至此,本輪事件循環已結束,執行棧爲空。console.log('promise then')
,打印出:promise then。至此,新的一輪事件循環(Promise事件)已結束,執行棧爲空。(注意:此時微任務隊列爲空)用動圖來展現一下執行的流程(備註:該demo網站並未畫出微任務隊列,咱們需本身腦補一下microtask queue):
咱們已經對單獨的宏任務和微任務的執行流程分別作了分析,如今讓咱們混合這兩種任務的事件來看看結果如何,來個代碼示例小試牛刀:
function foo() {
console.log('foo')
}
console.log('global start')
setTimeout(() => {
console.log('setTimeout: 0s')
}, 0)
new Promise((resolve) => {
console.log('promise')
resolve()
}).then(() => {
console.log('promise then')
})
foo()
console.log('global end')
複製代碼
控制檯輸出的結果爲:
global start
promise
foo
global end
promise then
setTimeout: 0S
複製代碼
代碼執行的解釋:
console.log('global start')
語句,打印出:global start。new Promise(....)
,執行之,Promise參數中的匿名函數同步執行,執行console.log('promise')
打印出:promise。在執行resolve()
以後Promise狀態變爲resolved,再繼續執行then(...)
,遇到then則將其提交給Web API處理,Web API將其添加到微任務隊列(注意:此時微任務隊列中已有一個Promise事件待處理)。foo()
,執行foo函數,打印出foo。console.log('global end')
,執行後打印出:global end。至此,本輪事件循環已結束,執行棧爲空。console.log('promise then')
,打印出:promise then。至此,新的一輪事件循環(Promise事件)已結束,執行棧爲空。(注意:此時微任務隊列爲空)console.log('setTimeout: 0s')
語句,打印出:setTimeout: 0s。至此,新的一輪事件循環(setTimeout事件)已結束,執行棧爲空。(注意:此時微任務隊列爲空,宏任務隊列也爲空)這個例子比較詳細地解釋了一遍,一共發生了三次事件循環。同理,仍是用個動圖來直觀地展現代碼執行過程吧!
相信耐心看到這的你已經對事件循環機制以及宏任務和微任務的執行順序有個清晰的瞭解了吧!不過,還沒結束哦,咱們async/await(不瞭解的人建議先去補習一下: async_function)還沒講呢!這裏簡單介紹下async函數:
async
關鍵字的做用就2點:①這個函數老是返回一個promise。②容許函數內使用await
關鍵字。await
使async函數一直等待(執行棧固然不可能停下來等待的,await將其後面的內容包裝成promise交給Web APIs後,執行棧會跳出async函數繼續執行),直到promise執行完並返回結果。await
只在async函數函數裏面奏效。像上面同樣,咱們先單獨拎出async函數來看看是怎麼樣個執行流程吧~
function foo() {
console.log('foo')
}
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('global start')
async1()
foo()
console.log('global end')
複製代碼
這裏就增長了兩個async函數:async一、async2。執行的結果以下:
global start
async1 start
async2
foo
global end
async1 end
複製代碼
咱們再來逐條解析一下代碼的執行過程吧(前面那些咱們已經懂的就不重複了):
console.log('global start')
,打印出:global start。async1()
,進入到async1函數體內,執行console.log('async1 start')
,打印出:async1 start。接着執行await async2()
,這裏await
關鍵字的做用就是await下面的代碼只有當await後面的promise返回結果後才能夠執行(此時,微任務隊列有一事件,其實就是Promise事件),而await async2()
語句就像執行普通函數同樣執行async2()
,進入到async2函數體中;執行console.log('async2')
,打印出:async2。async2函數執行結束彈出執行棧。await
關鍵字以後的語句已經被暫停,那麼async1函數執行結束,彈出執行棧。JS主線程繼續向下執行,執行foo()
函數打印出:foo。console.log('global end')
,打印出:global end。該語句以後再無其餘需執行的代碼,執行棧爲空,則本輪事件執行結束。await async2()
語句,async2函數執行完畢後,promise狀態變爲settled,以後的代碼就能夠繼續執行了(能夠這麼理解:用一個匿名函數包裹await
語句以後的代碼做爲一個微任務事件),執行console.log('async1 end')
語句,打印出:async1 end。執行棧又爲空,本輪事件也執行結束。至此,單一事件類型咱們都掌握了,下面咱們綜合演練一下!
這裏來幾道常見的題目來考察本身的掌握程度以及進一步鞏固吧!這裏再也不逐步分析了,有困惑的能夠留言再解答。
//請寫出輸出內容
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0)
async1();
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
console.log('script end');
複製代碼
輸出結果:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
複製代碼
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
//async2作出以下更改:
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
}
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0)
async1();
new Promise(function(resolve) {
console.log('promise3');
resolve();
}).then(function() {
console.log('promise4');
});
console.log('script end');
複製代碼
輸出的結果:
script start
async1 start
promise1
promise3
script end
promise2
async1 end
promise4
setTimeout
複製代碼
async function async1() {
console.log('async1 start');
await async2();
//更改以下:
setTimeout(function() {
console.log('setTimeout1')
},0)
}
async function async2() {
//更改以下:
setTimeout(function() {
console.log('setTimeout2')
},0)
}
console.log('script start');
setTimeout(function() {
console.log('setTimeout3');
}, 0)
async1();
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
console.log('script end');
複製代碼
輸出的結果:
script start
async1 start
promise1
script end
promise2
setTimeout3
setTimeout2
setTimeout1
複製代碼
async function a1 () {
console.log('a1 start')
await a2()
console.log('a1 end')
}
async function a2 () {
console.log('a2')
}
console.log('script start')
setTimeout(() => {
console.log('setTimeout')
}, 0)
Promise.resolve().then(() => {
console.log('promise1')
})
a1()
let promise2 = new Promise((resolve) => {
resolve('promise2.then')
console.log('promise2')
})
promise2.then((res) => {
console.log(res)
Promise.resolve().then(() => {
console.log('promise3')
})
})
console.log('script end')
複製代碼
輸出的結果:
script start
a1 start
a2
promise2
script end
promise1
a1 end
promise2.then
promise3
setTimeout
複製代碼
怎麼樣,都作對了嗎?其實就是將這幾個異步事件糅合在一塊兒罷了,只要咱們分別掌握了它們的執行過程,一步步拆開分析,一點都不難,這都是紙老虎而已!
呼~長吁一口氣,相信看到這的你已經疲憊了,不過恭喜:你應該徹底掌握了事件循環以及異步執行機制了吧!最後,讓咱們再總結一下本文涉及的要點吧!
JS是單線程執行的,同一時間只能處理一件事。可是瀏覽器是有多個線程的,JS引擎經過分發這些耗時的異步事件(AJAX請求、DOM操做等)給Wep APIs線程處理,所以避免了單線程被耗時的異步事件阻塞的問題。
Web APIs線程會將接收到的全部事件中已完成的事件根據類別分別將它們添加到相應的任務隊列中。其中任務隊列分如下兩種:
task queue
,也即本文圖中的callback queue
,macrotask是咱們給它的別名,緣由只是爲了與ES6新增的microtask隊列做區分而這樣稱呼,HTML標準中並無macrotask這種說法。它存放的是DOM事件、AJAX事件、setTimeout事件等。事件循環(event loop) 機制是爲了協調事件(events)、用戶交互(user interaction)、JS腳本(scripts)、頁面渲染(rendering)、網絡請求(networking)等等事件的有序執行而設置(定義由HTML標準給出,實現方式是靠各個瀏覽器廠商本身實現)。事件循環的過程以下:
打個比方幫助理解:宏任務事件就像是普通用戶,而微任務事件就像是VIP用戶,執行棧要先把全部在等待的VIP用戶服務好了之後才能給在等待的普通用戶服務,並且每次服務完一個普通用戶之後都要先看看有沒有VIP用戶在等待,如有,則VIP用戶優先(PS:人民幣玩家真的能夠隨心所欲,hah...)。固然,執行棧正在給一個普通用戶服務的時候,這時即便來了VIP用戶,他也是須要等待執行棧服務完該普通用戶後才能輪到他。
setTimeout設置的時間其實只是最小延遲時間,並非確切的等待時間。實際上最小延時 >=4ms,小於4ms的會被當作4ms。
promise 對象是由關鍵字 new 及Promise構造函數來建立的。該構造函數會把一個叫作「處理器函數」(executor function)的函數做爲它的參數(即 new Promise(...)
中的...
的內容)。這個「處理器函數」是在promise建立時是自動執行的,.then
以後的內容纔是異步內容,會交給Web APIs處理,而後被添加到微任務隊列。
async/await:async函數實際上是Generator函數的語法糖(解釋一下「語法糖」:就是添加標準之外的語法以方便開發人員使用,本質上仍是基於已有標準提供的語法進行封裝來實現的),async function 聲明用於定義一個返回 AsyncFunction 對象的異步函數。執行async函數時,遇到await
關鍵字時,await 語句產生一個promise,await 語句以後的代碼被暫停執行,等promise有結果(狀態變爲settled)之後再接着執行。
呼~ 結束了,休息一下~
參考文獻: