前面的話
本文將詳細介紹javascript中的事件循環event-loop,目標是讓你徹底弄懂JavaScript的執行機制。
不論是在你面試求職,或是日常開發工作中,我們經常會遇到這樣的情況:給定的幾行代碼,我們需要知道其輸出內容和順序。因爲javascript是一門單線程語言,所以我們可以得出結論:
- javascript是按照語句出現的順序執行的
那麼我們以爲的JS代碼可能是長這樣的:
let a = '我是第一';
console.log(a);
let b = '我是第二';
console.log(b);
然而實際開發中js是這樣的:
setTimeout(function(){
console.log('開始定時器')
});
new Promise(function(resolve){
console.log('創建promise啦');
for(var i = 0; i < 10000; i++){
i == 99 && resolve();
}
}).then(function(){
console.log('執行then方法咯')
});
console.log('代碼全都執行完啦');
依照JS是按照代碼順序執行這個理念,你可能會自信的寫下輸出結果:
//"開始定時器"
//"創建promise啦"
//"執行then方法咯"
//"代碼全都執行完啦"
結果丟到chrome去驗證下,結果完全不對,瞬間懵了,說好的一行一行執行的呢?
執行結果如下:
// 創建promise啦
// 代碼全都執行完啦
// 執行then方法咯
// 開始定時器
所以爲了日後的開發和麪試,我們真的要徹底弄明白JavaScript的執行機制了。
1、關於JavaScript
javascript是單線程的語言,也就是說,同一個時間只能做一件事。而這個單線程的特性,與它的用途有關,作爲瀏覽器腳本語言,JavaScript的主要用途是與用戶互動,以及操作DOM。這決定了它只能是單線程,否則會帶來很複雜的同步問題。比如,假定JavaScript同時有兩個線程,一個線程在某個DOM節點上添加內容,另一個線程刪除了這個節點,這時瀏覽器應該以哪個線程爲準?
2、線程
爲了利用多核CPU的計算能力,HTML5提出Web Worker標準,允許JavaScript腳本創建多個線程,但是子線程完全受主線程控制,且不得操作DOM。所以,這個新標準並沒有改變JavaScript單線程的本質。
排隊執行
單線程就意味着,所有任務需要排隊,前一個任務結束,纔會執行後一個任務。如果前一個任務耗時很長,後一個任務就不得不一直等着。但是在處理網絡請求時這是不合適的。因爲一個網絡請求的資源什麼時候返回是不可預知的,這種情況再排隊等待就不明智了。
3、同步和異步
所以,人們將任務分成了同步任務和異步任務。當我們打開網站時,網頁的渲染過程就是一大堆同步任務,比如頁面骨架和頁面元素的渲染。而像加載圖片音樂之類佔用資源大耗時久的任務,就是異步任務。
同步
如果在函數返回的時候,調用者就能夠馬上得到預期結果(即拿到了預期的返回值或者看到了預期的效果),那麼這個函數就是同步的。
Math.abs(-5);
console.log('hello world');
在第一個函數返回時,就拿到了預期的返回值:-5 的絕對值;
在第二個函數返回時候,就看到了預期的效果:在控制檯打印了一個字符串,所以這兩個函數都是同步的。
異步
如果在函數返回的時候,調用者還不能夠馬上得到預期的結果,而是需要在將來通過一定的手段得到,那麼這個函數就是異步的。一般通過傳入回調函數來得到異步函數返回結果。
axios({
method: "get",
url: `url`
}).then(resp => {
console.log("請求成功", resp);
}.catch(resp => {
console.log("請求失敗", resp);
});
在上面代碼中我們通過axios發送網絡請求,不會馬上得到我們期望的結果,只有在網絡請求完成後,我們才能打印出結果,所以axios函數是異步的。
正是由於JavaScript是單線程的,而異步容易實現非阻塞,所以在JavaScript中對於耗時的操作或者時間不確定的操作,使用異步就成了必然的選擇。
對於同步任務來說,按順序執行即可;但是,對於異步任務,各任務執行的時間長短不同,執行完成的時間點也不同,主線程如何調控異步任務呢?這就用到了消息隊列。
3、消息隊列
消息隊列有很多種叫法,任務隊列,或者叫事件隊列,總之它是一個和異步任務相關的隊列。
可以確定的是,它是隊列這種先入先出的數據結構,和排隊是類似的,哪個異步任務完成的早,就排在前面。不論異步操作何時開始執行,只要異步操作執行完成,就可以到消息隊列中排隊這樣,主線程在空閒的時候,就可以從消息隊列中獲取消息並執行。
消息隊列中放的消息具體是什麼東西?消息的具體結構當然跟具體的實現有關。但是爲了簡單起見,可以認爲:消息就是註冊異步任務時添加的回調函數。
可視化描述
人們把javascript調控同步和異步任務的機制稱爲事件循環,首先來看事件循環機制的可視化描述
棧
函數調用形成了一個棧幀
function foo(b) {
var a = 10;
return a + b + 11;
}
function bar(x) {
var y = 3;
return foo(x * y);
}
console.log(bar(7));
當調用bar
時,創建了第一個幀 ,幀中包含了bar
的參數和局部變量。當bar
調用foo
時,第二個幀就被創建,並被壓到第一個幀之上,幀中包含了foo
的參數和局部變量。當foo
返回時,最上層的幀就被彈出棧(剩下bar
函數的調用幀 )。當bar
返回的時候,棧就空了。
堆
對象被分配在一個堆中,即用以表示一個大部分非結構化的內存區域。
隊列
一個 JavaScript 運行時包含了一個待處理的消息隊列。每一個消息都與一個函數相關聯。當棧擁有足夠內存時,從隊列中取出一個消息進行處理。這個處理過程包含了調用與這個消息相關聯的函數(以及因而創建了一個初始堆棧幀)。當棧再次爲空的時候,也就意味着消息處理結束。
同步異步任務的執行機制
上述流程圖用文字表述是這樣的:
- 同步和異步任務分別進入不同的執行"場所",同步的進入主線程,異步的進入Event Table並註冊函數。
- 當指定的事情完成時,Event Table會將這個函數移入Event Queue()。
- 主線程內的任務執行完畢爲空,會去Event Queue讀取對應的函數,進入主線程執行。
- 上述過程會不斷重複,也就是常說的Event Loop(事件循環)。
通過對線程、同步異步和消息隊列的簡單瞭解,我們對JS的執行機制有了一定的認識。根據上圖的認識,我們不禁要問了,那怎麼知道主線程執行棧爲空啊?js引擎存在monitoring process進程,會持續不斷的檢查主線程執行棧是否爲空,一旦爲空,就會去Event Queue那裏檢查是否有等待被調用的函數。
接下來就可以詳細的講述事件循環了。
4、事件循環
下面來詳細介紹事件循環。下圖中,主線程運行的時候,產生堆和棧,棧中的代碼調用各種外部API,異步操作執行完成後,就在消息隊列中排隊。只要棧中的代碼執行完畢,主線程就會去讀取消息隊列,依次執行那些異步任務所對應的回調函數。
詳細步驟如下:
- 所有同步任務都在主線程上執行,形成一個執行棧.
- 主線程之外,還存在一個"消息隊列"。只要異步操作執行完成,就到消息隊列中排隊。
- 一旦執行棧中的所有同步任務執行完畢,系統就會按次序讀取消息隊列中的異步任務,於是被讀取的異步任務結束等待狀態,進入執行棧,開始執行。
- 主線程不斷重複上面的第三步。
循環
從代碼執行順序的角度來看,程序最開始是按代碼順序執行代碼的,遇到同步任務,立刻執行。
遇到異步任務,則只是調用異步函數發起異步請求。此時,異步任務開始執行異步操作,執行完成後到消息隊列中排隊。
程序按照代碼順序執行完畢後,查詢消息隊列中是否有等待的消息。如果有,則按照次序從消息隊列中把消息放到執行棧中執行。執行完畢後,再從消息隊列中獲取消息,再執行,不斷重複。
由於主線程不斷的重複獲得消息、執行消息、再取消息、再執行。所以,這種機制被稱爲事件循環。用代碼表示大概是這樣:
while (queue.waitForMessage()) {
queue.processNextMessage();
}
如果當前沒有任何消息queue.waitForMessage
會等待同步消息到達。
事件
爲什麼叫事件循環?而不叫任務循環或消息循環。究其原因是消息隊列中的每條消息實際上都對應着一個事件,DOM操作對應的是DOM事件,資源加載操作對應的是加載事件,而定時器操作可以看做對應一個“時間到了”的事件。
5、宏任務和微任務
簡單的來說,微任務和宏任務皆爲異步任務,但是微任務的優先級高於宏任務。
宏任務類型(macro-task):包括整體代碼script,setTimeout,setInterval
微任務類型(micro-task):
不同類型的任務會進入對應的Event Queue,比如setTimeout
和setInterval
會進入相同的Event Queue。
事件循環的順序,決定js代碼的執行順序。進入整體代碼(宏任務)後,開始第一次循環。接着執行所有的微任務。然後再次從宏任務開始,找到其中一個任務隊列執行完畢,再執行所有的微任務。聽起來有點繞,我們用本系列文章最開始的一段代碼說明:
setTimeout(function(){
console.log('開始定時器')
});
new Promise(function(resolve){
console.log('創建promise啦');
for(var i = 0; i < 10000; i++){
i == 99 && resolve();
}
}).then(function(){
console.log('執行then方法咯')
});
console.log('代碼全都執行完啦');
- 這段代碼作爲宏任務,進入主線程。
- 先遇到
setTimeout
,那麼將其回調函數註冊後分發到宏任務Event Queue。(註冊過程與上同,下文不再描述) - 接下來遇到了
Promise
,new Promise
立即執行,打印文字,then
函數分發到微任務Event Queue。 - 遇到
console.log()
,立即執行,打印文字。 - 好啦,整體代碼script作爲第一個宏任務執行結束,看看有哪些微任務?我們發現了在微任務Event Queue裏面有Promise.then方法,執行。
- ok,第一輪事件循環結束了,我們開始第二輪循環,當然要從宏任務Event Queue開始。我們發現了宏任務Event Queue中
setTimeout
對應的回調函數,立即執行。 - 結束。
6、寫在最後
js的異步
我們從最開頭就說javascript是一門單線程語言,不管是什麼新框架新語法糖實現的所謂異步,其實都是用同步的方法去模擬的,牢牢把握住單線程這點非常重要。
事件循環Event Loop
事件循環是js實現異步的一種方法,也是js的執行機制。
JavaScript的執行和運行
執行和運行有很大的區別,javascript在不同的環境下,比如node,瀏覽器,Ringo等等,執行方式是不同的。而運行大多指javascript解析引擎,是統一的。
setImmediate
微任務和宏任務還有很多種類,比如setImmediate
等等,執行都是有共同點的,有興趣的同學可以自行了解。
最後的最後
- javascript是一門單線程語言
- Event Loop是javascript的執行機制
牢牢把握兩個基本點,以認真學習javascript爲中心,早日實現成爲前端高手的偉大夢想!
參考資料:
深入理解javascript中的事件循環event-loop