本篇文章給大家帶來的內容是關於Javascript事件環該如何理解?(圖文),有一定的參考價值,有需要的朋友可以參考一下,希望對你有所協助。
棧和隊列
在電腦記憶體中存取資料,基本的資料結構分為棧和隊列。
棧(Stack)是一種後進先出的資料結構,注意,有時候也管棧叫做“堆棧”,但是“堆”又是另一種複雜的資料結構,它和棧完全是兩碼事。棧的特點是操作只在一端進行,一般來說,棧的操作只有兩種:進棧和出棧。第一個進棧的資料總是最後一個才出來。
隊列(Queue)和棧類似,但它是先進先出的資料結構,插入資料的操作從隊列的一端進行,而刪除的操作在另一端。
通俗的比喻棧就像是一個立好的桶,先放入棧的資料會放在桶底,出棧時會在桶口一一將資料取出,所以最先放入棧的資料總是最後一個才能取出。而隊列就像是一個水管,最先放入隊列的資料會第一個從隊列的另一端流出,這是它們最大的區別。
在javascript中,函數的執行就一個典型的入棧與出棧的過程:
function fun1() { function fun2() { function fun3() { console.log('do it'); } fun3(); } fun2();}fun1();
在程式執行時,首先將fun1,fun2,fun3依次入棧,而在調用函數時,是先將fun3調用(出棧),再是fun2和fun1,試想一下,如果fun1先出棧,那麼函數fun2和fun3必將丟失。
單線程和非同步
在javascript這門語言中程式是單線程的,只有一個主線程,這是為什嗎?因為不難想像,最初javascript的設計是跑在瀏覽器中的指令碼語言,如果設計成多線程,兩個線程同時修改DOM那以誰的為準呢?所以javascript為單線程,在一個線程中代碼會一句一句向下走,直到程式跑完,若中間有較為費時的操作,那也只能等著。
單線程的設計使得語言的執行效率很差,為了利用多核心CPU的效能,javascript語言支援非同步代碼,當有較為費時的操作時,可將任務寫為非同步執行,當一個非同步任務還沒有執行完時,主線程會將非同步任務掛起,繼續執行後面的同步代碼,之後再回過頭來看,如果有非同步任務運行完了再執行它。
這種執行代碼的方式其實很符合我們生活中的很多情境,比如小明同學下班回家了,他很渴,想燒水泡茶,如果是同步的執行方式那就是燒水,在水沒開時小明像個傻子似的等著,等水開了再泡茶;若是非同步執行,小明先開始燒水,然後就去幹點別的事,比如看會電視、聽聽音樂,等水燒開了再去泡茶。明顯第二種非同步方式效率更高。
常見的非同步作業都有哪些?有很多,我們可以羅列幾個常見的:
Ajax
DOM的事件操作
setTimeout
Promise的then方法
Node的讀取檔案
我們先來看一段代碼:
//樣本1console.log(1);setTimeout(function () { console.log(2);}, 1000);console.log(3);
這段代碼非常簡單,把它們放在瀏覽器中執行結果如下:
132
因為setTimeout函數延時了1000毫秒執行,因此先輸出1和3,而2是過了1000毫秒之後再輸出,這很合邏輯。
我們稍稍改動一下代碼,將setTimeout的延時時間改為0:
//樣本2console.log(1);setTimeout(function () { console.log(2);}, 0); //0毫秒,不延時console.log(3);
運行結果:
132
為什麼延時了0毫秒還是最後輸出的2?先別急,我們再來看一段代碼:
//樣本3console.log(1);setTimeout(function () { console.log(2);}, 0);Promise.resolve().then(function(){ console.log(3);});console.log(4);
運行結果:
1432
以上三段代碼,如果你能正確的寫出結果,並且能說明白為什麼這樣輸出,說明你對javascript的事件環理解的很清楚,如果講不出來,我們就一起聊聊這裡面發生了什麼,其實很有意思。
javascript是怎麼執行的?
一開始先簡單聊了聊基本的資料結構,它和我們現在說的事件環有什麼關係嗎?當然有,首先要明確的一點是,javascript代碼的執行全都在棧裡,不論是同步代碼還是非同步代碼,這個一定要清楚。
而代碼我們大體上分為了同步代碼和非同步代碼,其實非同步代碼還可以再分為兩類:宏任務和微任務。
先別管什麼是宏任務和微任務,往往這種高大上的術語不利於我們理解,我們先這麼認為:宏,即是宏觀的、大的;微即微觀的、小的。
javascript是解釋型語言,它的執行過程是這樣的:
從上到下依次解釋每一條js語句
若是同步任務,則壓入一個棧(主線程);如果是非同步任務,就放到一個任務隊列裡
開始執行棧裡的同步任務,直到將棧裡的所有任務都走完,此時棧清空了
回過頭看非同步隊列裡如果有非同步任務完成了,就產生一個事件並註冊回調,壓入棧中
再返回第3步,直到非同步隊列都清空,程式運行結束
語言描述的費勁,不如看圖:
通過以上的步驟可以看到,不論是同步還是非同步,只要是執行的時候都是要在棧裡執行的,而一遍又一遍的回頭檢查非同步隊列,這種執行方式 就是所謂的“事件環”。
明白了javascript的執行原理,我們就不難理解之前的第二段代碼,為什麼setTimeout為0時會最後執行,因為setTimeout是非同步代碼,必須要等所有的同步代碼都執行完,才會執行非同步隊列。即使setTimeout執行得再快,它也不可能在同步代碼之前執行。
瀏覽器中的事件環
聊了這麼多,我們好像還沒有說宏任務和微任務的話題呢,上面說了,非同步任務又分為微任務和宏任務,那它們又是一個怎樣的執行機制呢?
注意!微任務和宏任務的執行方式在瀏覽器和Node中有差異,有差異!重要的事我們多說幾遍,以下我們討論的是在瀏覽器的環境裡。
在瀏覽器的執行環境中,總是先執行小的、微任務,再執行大的、宏任務,回過頭再看看第三段代碼,為什麼Promise的then方法在setTimeout之前執行?其根本原理就是因為Promise的then方法是一個微任務,而setTimeout是一個宏任務。
接下來我們借用阮一峰老師的一張圖來說明:
其實,以上這張圖示我們可以再將它細化一點,這個圖上的非同步隊列只畫了一個,也就是說沒有區分微任務隊列和宏任務隊列。我們可以腦補一下,在此圖上多加一個微任務隊列,當javascript執行時再多加一個判斷,如果是微任務就加到微任務隊列裡,宏任務就加到宏任務隊列裡,在清空隊列時,瀏覽器總會優先清空“微任務”。這樣就把瀏覽器的事件環撤底說全了。
最後來一個大考,以下代碼的運行結果是什麼:
<script type="text/javascript"> setTimeout(function () { console.log(1); Promise.resolve().then(function () { console.log(2); }); }); setTimeout(function () { console.log(3); }); Promise.resolve().then(function () { console.log(4); }); console.log(5);</script>
將此代碼拷到chrome中跑一下,結果是:
54123
不妨我們試著分析一下為什麼是這個結果,首先輸出5,因為console.log(5)
是同步代碼,這沒什麼可說的。
之後將前兩個setTimeout和最後一個Promise放入非同步隊列,注意它們的區分,此時執行完了同步代碼之後發現微任務和宏任務隊列中都有代碼,按瀏覽器的事件環機制,優先執行微任務,此時輸出4。
然後執行宏任務隊列裡的第一個setTimeout,輸出1。
此時,setTimeout中又有一個Promise,放入微任務隊列。
再次清空微任務隊列,輸出2。
最後宏任務隊列裡還有最後一個setTimeout,輸出3。
Node中的事件環
而Node中的事件環又和瀏覽器有些許的不同,在node.js的官方文檔中有專門的描述,其中文檔中有一張圖,詳細的說明了它的事件環機制,我們把它拿出來:
可以看到,node.js中的事件環機制分為了6個階段,其中最重要的3個階段我在上面做了註明:
圖中每一個階段都代表了一個宏任務隊列,在Node事件環中,微任務的運行時機是在每一個“宏任務隊列”清空之後,在進入下一個宏任務隊列之間執行。這是和瀏覽器的最大區別。
還是用代碼說話吧,有一道經典的Node.js事件環面試題:
const fs = require('fs');fs.readFile('./1.txt', (err, data) => { setTimeout(() => { console.log('timeout'); }); setImmediate(() => { console.log('immediate'); }); Promise.resolve().then(() => { console.log('Promise'); });});
運行結果:
Promiseimmediatetimeout
代碼並不複雜,首先使用fs模組讀取了一個檔案,在回調的內部有兩個宏任務和一個微任務,微任務總是優於宏任務執行的,因此先輸出Promise。
但是之後的區別為什麼先輸出immdiate?原因就在於fs讀取檔案的宏任務在中的第4個輪詢階段,當第4個階段清空隊列之後,就該進入第5個check階段,也就是setImmediate這個宏任務所在的階段,而不會跳回第1個階段,因此先輸出immedate。
尾巴
最後總結一下,分析完瀏覽器和Node的事件環發現它們並不簡單,但只要記住了它們之間的區別就可以分析出結果。
瀏覽器事件環是運行完一個宏任務馬上清空微任務隊列。
Node事件環是清空完一個階段的宏任務隊列之後再清空微任務隊列。
最後,總結一下常見的宏任務和微任務:
宏任務 |
微任務 |
setTimeout |
Promise的then方法 |
setInterval |
process.nextTick |
setImmediate |
MutationObserver |
MessageChannel |
|