前面突然想起一個問題,我們常說棧的存儲是先進后出,那么事件循環里面的事件為啥是先進先執行的呢,這不是和棧存儲方式向駁論了嗎,還有所謂的宏任務和微任務的調用優先級之分是如何處理的呢,基於弄清楚其中的具體流程和機制,這里做一份總結記錄
背景
JS是單線程
JavaScript語言的一大特點就是單線程,也就是說,同一個時間只能做一件事。那么,為什么JavaScript不能有多個線程呢?這樣能提高效率啊。(在JAVA和c#中的異步均是通過多線程實現的,沒有循環隊列一說,直接在子線程中完成相關的操作)
JavaScript的單線程,與它的用途有關。作為瀏覽器腳本語言,JavaScript的主要用途是與用戶互動,以及操作DOM。這決定了它只能是單線程,否則會帶來很復雜的同步問題。比如,假定JavaScript同時有兩個線程,一個線程在某個DOM節點上添加內容,另一個線程刪除了這個節點,這時瀏覽器應該以哪個線程為准?
所以,為了避免復雜性,從一誕生,JavaScript就是單線程,這已經成了這門語言的核心特征,將來也不會改變
為了利用多核CPU的計算能力,HTML5提出Web Worker標准,允許JavaScript腳本創建多個線程,但是子線程完全受主線程控制,且不得操作DOM。所以,這個新標准並沒有改變JavaScript單線程的本質
-
JS解析線程 (javaScript屬於單線程,每次只能去處理一件事)
-
GUI渲染線程 (可以理解成解析加載css tree和 dom tree,生成render tree生成頁面,包括重繪都是會觸發GUI線程,與此同時 ,GUI線程和JS主線程是互斥的,即不能同時存在)
-
網絡請求線程 (它可以異步處理http請求,請求回來的數據仍在事件隊列線程中,等JS線程空下來之后, 才會推到JS線程中執行, 屬於微任務)
-
定時器線程 ( 指的是setTimeout,setInterval,JS線程沒辦法讀秒,所以讀秒的任務就是定時器線程在做, 定時器屬於宏任務)
-
事件隊列線程 ( 這個線程指的是異步回調結束之后, 暫時放在這個線程中,等待JS線程空下來后再次執行 )
如下:
JS的異步是通過回調函數實現的,即通過任務隊列,在主線程執行完當前的任務棧(所有的同步操作),主線程空閑后輪詢任務隊列,並將任務隊列中的任務(回調函數)取出來執行。"回調函數"(callback),就是那些會被主線程掛起來的代碼。異步任務必須指定回調函數,當主線程開始執行異步任務,就是執行對應的回調函數。
雖然JS是單線程的但是瀏覽器的內核是多線程的,在瀏覽器的內核中不同的異步操作由不同的瀏覽器內核模塊調度執行,異步操作會將相關回調添加到任務隊列中。而不同的異步操作添加到任務隊列的時機也不同,如 onclick, setTimeout, ajax 處理的方式都不同,這些異步操作是由瀏覽器內核的 webcore 來執行的,webcore 包含上圖中的3種 webAPI,分別是 DOM Binding、network、timer模塊
-
-
setTimeout 會由瀏覽器內核的 timer 模塊來進行延時處理,當時間到達的時候,才會將回調函數添加到任務隊列中
-
ajax 則會由瀏覽器內核的 network 模塊來處理,在網絡請求完成返回之后,才將回調添加到任務隊列中
總結得出異步運行機制如下:
-
所有同步任務都在主線程上執行,形成一個執行棧(execution context stack)
-
主線程之外,還存在一個"任務隊列"(task queue),只要異步任務有了運行結果,就在"任務隊列"之中放置一個事件
-
一旦"執行棧"中的所有同步任務執行完畢,系統就會讀取"任務隊列",看看里面有哪些事件。那些對應的異步任務,於是結束等待狀態,進入執行棧,開始執行
-
主線程不斷重復上面的第三步
只要主線程空了,就會去讀取"任務隊列",這就是JavaScript的運行機制。這個過程會不斷重復。(該過程又稱之為事件輪詢)
執行棧
所有的 JS 代碼在運行時都是在執行上下文中進行的。執行上下文是一個抽象的概念,JS 中有三種執行上下文:
-
全局執行上下文,默認的,在瀏覽器中是 window 對象,並且 this 在非嚴格模式下指向它
-
函數執行上下文,JS 的函數每當被調用時會創建一個上下文
-
Eval 執行上下文,eval 函數會產生自己的上下文,這里不討論
通常,我們的代碼中都不止一個上下文,那這些上下文的執行順序應該是怎樣的?從上往下依次執行?
棧,是一種數據結構,具有先進后出的原則。JS 中的執行棧就具有這樣的結構,當引擎第一次遇到 JS 代碼時,會產生一個全局執行上下文並壓入執行棧,每遇到一個函數調用,就會往棧中壓入一個新的上下文。引擎執行棧頂的函數,執行完畢,彈出當前執行上下文
function foo() { console.log('1') bar(); console.log('3') } function bar() { console.log('2') } foo()
以引例來說明。當 foo() 函數被調用,將 foo 函數的執行上下文壓入執行棧,接着執行輸出 ‘1’;當 bar() 函數被調用,將 bar 函數的執行上下文壓入執行棧,接着執行輸出 ‘2’;bar() 執行完畢,被彈出執行棧,foo() 函數接着執行,輸出 ‘3’;foo() 函數執行完畢,被彈出執行棧
調用堆棧顧名思義是一個具有LIFO(后進先出)結構的堆棧,用於存儲在代碼執行期間創建的所有執行上下文
JS 只有一個調用棧,因為它是一種單線程編程語言,調用堆棧具有 LIFO
結構,這意味着項目只能從堆棧頂部添加或刪除
回到代碼,嘗試理解代該碼是如何在JS
引擎中執行
const second = () => { console.log('Hello there!') } const first = () => { console.log('Hi there!') second(); console.log('The End') } first()
接下來,console.log('Hi there!')被推送到堆棧的頂部,當它完成時,它會從堆棧中彈出。之后,我們調用second(),因此second()函數被推到堆棧的頂部
console.log('Hello there!')被推送到堆棧頂部,並在完成時彈出堆棧。second() 函數結束,因此它從堆棧中彈出
console.log(“the End”)被推到堆棧的頂部,並在完成時刪除。之后,first()函數完成,因此從堆棧中刪除它
程序在這一點上完成了它的執行,所以全局執行上下文(main())從堆棧中彈出
可以看出其實執行棧和調用堆棧是可以相互嵌套的
事件輪詢的工作是監聽調用堆棧,並確定調用堆棧是否為空。如果調用堆棧是空的,它將檢查消息隊列,看看是否有任何掛起的回調等待執行
在這種情況下,消息隊列包含一個回調,此時調用堆棧為空。因此,事件輪詢將回調推到堆棧的頂部
然后是 console.log(“Async Code”) 被推送到堆棧頂部,執行並從堆棧中彈出。此時,回調已經完成,因此從堆棧中刪除它,程序最終完成
消息隊列還包含來自DOM事件(如單擊事件和鍵盤事件)的回調。例如:
document.querySelector('.btn').addEventListener('click',(event) => { console.log('Button Clicked') })
對於DOM事件,事件偵聽器位於web api環境中,等待某個事件(在本例中單擊event)發生,當該事件發生時,回調函數被放置在等待執行的消息隊列中
同樣,事件輪詢檢查調用堆棧是否為空,並在調用堆棧為空並執行回調時將事件回調推送到堆棧
在JS中ES6 中新增的任務隊列(promise)是在事件循環之上的,事件循環每次 tick 后會查看 ES6 的任務隊列中是否有任務要執行,也就是 ES6 的任務隊列比事件循環中的任務(事件)隊列優先級更高
如 Promise 就使用了 ES6 的任務隊列特性。也即在執行完任務棧后首先執行的是任務隊列中的promise任務,也就是我們常說的微任務。其他的上面常見的異步操作加入隊列的時間沒有相應的優先級
總結
所以開文所說的棧的存儲先進后出沒有錯,因為我們的調用堆棧就是這么存儲的,事件的優先級和棧的存儲關系並不大,因為事件未推入調用堆棧前是使用隊列來存儲的,所以並未駁論