您好,登錄后才能下訂單哦!
今天小編給大家分享一下JavaScript事件循環的原理是什么的相關知識點,內容詳細,邏輯清晰,相信大部分人都還太了解這方面的知識,所以分享這篇文章給大家參考一下,希望大家閱讀完這篇文章后有所收獲,下面我們一起來了解一下吧。
理解 JavaScript 的事件循環往往伴隨著宏任務和微任務、JavaScript 單線程執行過程及瀏覽器異步機制等相關問題,而瀏覽器和 NodeJS 中的事件循環實現也是有很大差別。熟悉事件循環,了解瀏覽器運行機制將對我們理解 JavaScript 的執行過程,以及在排查代碼運行問題時有很大幫助。
JS 是單線程的,也就是同一個時刻只能做一件事情,那么思考:為什么瀏覽器可以同時執行異步任務呢?
因為瀏覽器是多線程的,當 JS 需要執行異步任務時,瀏覽器會另外啟動一個線程去執行該任務。也就是說,“JS 是單線程的”指的是執行 JS 代碼的線程只有一個,是瀏覽器提供的 JS 引擎線程(主線程)。瀏覽器中還有定時器線程和 HTTP 請求線程等,這些線程主要不是來跑 JS 代碼的。
比如主線程中需要發一個 AJAX 請求,就把這個任務交給另一個瀏覽器線程(HTTP 請求線程)去真正發送請求,待請求回來了,再將 callback 里需要執行的 JS 回調交給 JS 引擎線程去執行。**即瀏覽器才是真正執行發送請求這個任務的角色,而 JS 只是負責執行最后的回調處理。**所以這里的異步不是 JS 自身實現的,其實是瀏覽器為其提供的能力。
以 Chrome 為例,瀏覽器不僅有多個線程,還有多個進程,如渲染進程、GPU 進程和插件進程等。而每個 tab 標簽頁都是一個獨立的渲染進程,所以一個 tab 異常崩潰后,其他 tab 基本不會被影響。作為前端開發者,主要重點關注其渲染進程,渲染進程下包含了 JS 引擎線程、HTTP 請求線程和定時器線程等,這些線程為 JS 在瀏覽器中完成異步任務提供了基礎。
瀏覽器異步任務的執行原理背后其實是一套事件驅動的機制。事件觸發、任務選擇和任務執行都是由事件驅動機制來完成的。NodeJS 和瀏覽器的設計都是基于事件驅動的,簡而言之就是由特定的事件來觸發特定的任務,這里的事件可以是用戶的操作觸發的,如 click 事件;也可以是程序自動觸發的,比如瀏覽器中定時器線程在計時結束后會觸發定時器事件。而本文的主題內容事件循環其實就是在事件驅動模式中來管理和執行事件的一套流程。
以一個簡單場景為例,假設游戲界面上有一個移動按鈕和人物模型,每次點擊右移后,人物模型的位置需要重新渲染,右移 1 像素。根據渲染時機的不同我們可以用不同的方式來實現。
實現方式一:事件驅動。 點擊按鈕后,修改坐標 positionX 時,立即觸發界面渲染的事件,觸發重新渲染。
實現方式二:狀態驅動或數據驅動。 點擊按鈕后,只修改坐標 positionX,不觸發界面渲染。在此之前會啟動一個定時器 setInterval,或者利用 requestAnimationFrame 來不斷地檢測 positionX 是否有變化。如果有變化,則立即重新渲染。
瀏覽器中的點擊事件處理也是典型的基于事件驅動。在事件驅動中,當有事件觸發后,被觸發的事件會按順序暫時存在一個隊列中,待 JS 的同步任務執行完成后,會從這個隊列中取出要處理的事件并進行處理。那么具體什么時候取任務、優先取哪些任務,這就由事件循環流程來控制了。
JS 在解析一段代碼時,會將同步代碼按順序排在某個地方,即執行棧,然后依次執行里面的函數。當遇到異步任務時就交給其他線程處理,待當前執行棧所有同步代碼執行完成后,會從一個隊列中去取出已完成的異步任務的回調加入執行棧繼續執行,遇到異步任務時又交給其他線程,.....,如此循環往復。而其他異步任務完成后,將回調放入任務隊列中待執行棧來取出執行。
JS 按順序執行執行棧中的方法,每次執行一個方法時,會為這個方法生成獨有的執行環境(上下文 context),待這個方法執行完成后,銷毀當前的執行環境,并從棧中彈出此方法(即消費完成),然后繼續下一個方法。
可見,在事件驅動的模式下,至少包含了一個執行循環來檢測任務隊列是否有新的任務。通過不斷循環去取出異步回調來執行,這個過程就是事件循環,而每一次循環就是一個事件周期或稱為一次 tick。
任務隊列不只一個,根據任務的種類不同,可以分為微任務(micro task)隊列和宏任務(macro task)隊列。
事件循環的過程中,執行棧在同步代碼執行完成后,優先檢查微任務隊列是否有任務需要執行,如果沒有,再去宏任務隊列檢查是否有任務執行,如此往復。微任務一般在當前循環就會優先執行,而宏任務會等到下一次循環,因此,微任務一般比宏任務先執行,并且微任務隊列只有一個,宏任務隊列可能有多個。另外我們常見的點擊和鍵盤等事件也屬于宏任務。
下面我們看一下常見宏任務和常見微任務。
常見宏任務:
setTimeout()
setInterval()
setImmediate()
常見微任務:
promise.then()、promise.catch()
new MutaionObserver()
process.nextTick()
console.log('同步代碼1');setTimeout(() => { console.log('setTimeout')
}, 0)new Promise((resolve) => { console.log('同步代碼2') resolve()
}).then(() => { console.log('promise.then')
})console.log('同步代碼3');// 最終輸出"同步代碼1"、"同步代碼2"、"同步代碼3"、"promise.then"、"setTimeout"
上面的代碼將按如下順序輸出為:"同步代碼 1"、"同步代碼 2"、"同步代碼 3"、"promise.then"、"setTimeout",具體分析如下。
(1)setTimeout 回調和 promise.then 都是異步執行的,將在所有同步代碼之后執行;
順便提一下,在瀏覽器中 setTimeout 的延時設置為 0 的話,會默認為 4ms,NodeJS 為 1ms。具體值可能不固定,但不是為 0。
(2)雖然 promise.then 寫在后面,但是執行順序卻比 setTimeout 優先,因為它是微任務;
(3)new Promise 是同步執行的,promise.then 里面的回調才是異步的。
下面我們看一下上面代碼的執行過程演示:
也有人這樣去理解:微任務是在當前事件循環的尾部去執行;宏任務是在下一次事件循環的開始去執行。我們來看看微任務和宏任務的本質區別是什么。
我們已經知道,JS 遇到異步任務時會將此任務交給其他線程去處理,自己的主線程繼續往后執行同步任務。比如 setTimeout 的計時會由瀏覽器的定時器線程來處理,待計時結束,就將定時器回調任務放入任務隊列等待主線程來取出執行。前面我們提到,因為 JS 是單線程執行的,所以要執行異步任務,就需要瀏覽器其他線程來輔助,即多線程是 JS 異步任務的一個明顯特征。
我們再來分析下 promise.then(微任務)的處理。當執行到 promise.then 時,V8 引擎不會將異步任務交給瀏覽器其他線程,而是將回調存在自己的一個隊列中,待當前執行棧執行完成后,立馬去執行 promise.then 存放的隊列,promise.then 微任務沒有多線程參與,甚至從某些角度說,微任務都不能完全算是異步,它只是將書寫時的代碼修改了執行順序而已。
setTimeout 有“定時等待”這個任務,需要定時器線程執行;ajax 請求有“發送請求”這個任務,需要 HTTP 線程執行,而 promise.then 它沒有任何異步任務需要其他線程執行,它只有回調,即使有,也只是內部嵌套的另一個宏任務。
簡單小結一下微任務和宏任務的本質區別。
宏任務特征:有明確的異步任務需要執行和回調;需要其他異步線程支持。
微任務特征:沒有明確的異步任務需要執行,只有回調;不需要其他異步線程支持。
事件循環中,總是先執行同步代碼后,才會去任務隊列中取出異步回調來執行。當執行 setTimeout 時,瀏覽器啟動新的線程去計時,計時結束后觸發定時器事件將回調存入宏任務隊列,等待 JS 主線程來取出執行。如果這時主線程還在執行同步任務的過程中,那么此時的宏任務就只有先掛起,這就造成了計時器不準確的問題。同步代碼耗時越長,計時器的誤差就越大。不僅同步代碼,由于微任務會優先執行,所以微任務也會影響計時,假設同步代碼中有一個死循環或者微任務中遞歸不斷在啟動其他微任務,那么宏任務里面的代碼可能永遠得不到執行。所以主線程代碼的執行效率提升是一件很重要的事情。
一個很簡單的場景就是我們界面上有一個時鐘精確到秒,每秒更新一次時間。你會發現有時候秒數會直接跳過 2 秒間隔,就是這個原因。
微任務隊列執行完成后,也就是一次事件循環結束后,瀏覽器會執行視圖渲染,當然這里會有瀏覽器的優化,可能會合并多次循環的結果做一次視圖重繪,因此視圖更新是在事件循環之后,所以并不是每一次操作 Dom 都一定會立馬刷新視圖。視圖重繪之前會先執行 requestAnimationFrame 回調,那么對于 requestAnimationFrame 是微任務還是宏任務是有爭議的,在這里看來,它應該既不屬于微任務,也不屬于宏任務。
JS 引擎本身不實現事件循環機制,這是由它的宿主實現的,瀏覽器中的事件循環主要是由瀏覽器來實現,而在 NodeJS 中也有自己的事件循環實現。NodeJS 中也是循環 + 任務隊列的流程以及微任務優先于宏任務,大致表現和瀏覽器是一致的。不過它與瀏覽器中也有一些差異,并且新增了一些任務類型和任務階段。接下來我們介紹下 NodeJS 中的事件循環流程。
因為都是基于 V8 引擎,瀏覽器中包含的異步方式在 NodeJS 中也是一樣的。另外 NodeJS 中還有一些其他常見異步形式。
文件 I/O:異步加載本地文件。
setImmediate():與 setTimeout 設置 0ms 類似,在某些同步任務完成后立馬執行。
process.nextTick():在某些同步任務完成后立馬執行。
server.close、socket.on('close',...)等:關閉回調。
想象一下,如果上面的形式和 setTimeout、promise 等同時存在,如何分析出代碼的執行順序呢?只要我們理解了 NodeJS 的事件循環機制,也就清楚了。
NodeJS 的跨平臺能力和事件循環機制都是基于 Libuv 庫實現的,你不用關心這個庫的具體內容。我們只需要知道 Libuv 庫是事件驅動的,并且封裝和統一了不同平臺的 API 實現。
NodeJS 中 V8 引擎將 JS 代碼解析后調用 Node API,然后 Node API 將任務交給 Libuv 去分配,最后再將執行結果返回給 V8 引擎。在 Libux 中實現了一套事件循環流程來管理這些任務的執行,所以 NodeJS 的事件循環主要是在 Libuv 中完成的。
下面我們來看看 Libuv 中的循環是怎樣的。
在 NodeJS 中 JS 的執行,我們主要需要關心的過程分為以下幾個階段,下面每個階段都有自己單獨的任務隊列,當執行到對應階段時,就判斷當前階段的任務隊列是否有需要處理的任務。
timers 階段:執行所有 setTimeout() 和 setInterval() 的回調。
pending callbacks 階段:某些系統操作的回調,如 TCP 鏈接錯誤。除了 timers、close、setImmediate 的其他大部分回調在此階段執行。
poll 階段:輪詢等待新的鏈接和請求等事件,執行 I/O 回調等。V8 引擎將 JS 代碼解析并傳入 Libuv 引擎后首先進入此階段。如果此階段任務隊列已經執行完了,則進入 check 階段執行 setImmediate 回調(如果有 setImmediate),或等待新的任務進來(如果沒有 setImmediate)。在等待新的任務時,如果有 timers 計時到期,則會直接進入 timers 階段。此階段可能會阻塞等待。
check 階段:setImmediate 回調函數執行。
close callbacks 階段:關閉回調執行,如 socket.on('close', ...)。
上面每個階段都會去執行完當前階段的任務隊列,然后繼續執行當前階段的微任務隊列,只有當前階段所有微任務都執行完了,才會進入下個階段。這里也是與瀏覽器中邏輯差異較大的地方,不過瀏覽器不用區分這些階段,也少了很多異步操作類型,所以不用刻意去區分兩者區別。代碼如下所示:
const fs = require('fs');
fs.readFile(__filename, (data) => { // poll(I/O 回調) 階段
console.log('readFile') Promise.resolve().then(() => { console.error('promise1')
}) Promise.resolve().then(() => { console.error('promise2')
})
});setTimeout(() => { // timers 階段
console.log('timeout'); Promise.resolve().then(() => { console.error('promise3')
}) Promise.resolve().then(() => { console.error('promise4')
})
}, 0);// 下面代碼只是為了同步阻塞1秒鐘,確保上面的異步任務已經準備好了var startTime = new Date().getTime();var endTime = startTime;while(endTime - startTime < 1000) {
endTime = new Date().getTime();
}// 最終輸出 timeout promise3 promise4 readFile promise1 promise2
另一個與瀏覽器的差異還體現在同一個階段里的不同任務執行,在 timers 階段里面的宏任務、微任務測試代碼如下所示:
setTimeout(() => { console.log('timeout1') Promise.resolve().then(function() { console.log('promise1')
})
}, 0);setTimeout(() => { console.log('timeout2') Promise.resolve().then(function() { console.log('promise2')
})
}, 0);
瀏覽器中運行
每次宏任務完成后都會優先處理微任務,輸出“timeout1”、“promise1”、“timeout2”、“promise2”。
NodeJS 中運行
因為輸出 timeout1 時,當前正處于 timers 階段,所以會先將所有 timer 回調執行完之后再執行微任務隊列,即輸出“timeout1”、“timeout2”、“promise1”、“promise2”。
上面的差異可以用瀏覽器和 NodeJS 10 對比驗證。是不是感覺有點反程序員?因此 NodeJS 在版本 11 之后,就修改了此處邏輯使其與瀏覽器盡量一致,也就是每個 timer 執行后都先去檢查一下微任務隊列,所以 NodeJS 11 之后的輸出已經和瀏覽器一致了。
實際項目中我們常用 Promise 或者 setTimeout 來做一些需要延時的任務,比如一些耗時計算或者日志上傳等,目的是不希望它的執行占用主線程的時間或者需要依賴整個同步代碼執行完成后的結果。
NodeJS 中的 process.nextTick() 和 setImmediate() 也有類似效果。其中 setImmediate() 我們前面已經講了是在 check 階段執行的,而 process.nextTick() 的執行時機不太一樣,它比 promise.then() 的執行還早,在同步任務之后,其他所有異步任務之前,會優先執行 nextTick。可以想象是把 nextTick 的任務放到了當前循環的后面,與 promise.then() 類似,但比 promise.then() 更前面。意思就是在當前同步代碼執行完成后,不管其他異步任務,先盡快執行 nextTick。如下面的代碼,因此這里的 nextTick 其實應該更符合“setImmediate”這個命名才對。
setTimeout(() => { console.log('timeout');
}, 0);Promise.resolve().then(() => { console.error('promise')
})
process.nextTick(() => { console.error('nextTick')
})// 輸出:nextTick、promise、timeout
接下來我們再來看看 setImmediate 和 setTimeout,它們是屬于不同的執行階段了,分別是 timers 階段和 check 階段。
setTimeout(() => { console.log('timeout');
}, 0);setImmediate(() => { console.log('setImmediate');
});// 輸出:timeout、 setImmediate
分析上面代碼,第一輪循環后,分別將 setTimeout 和 setImmediate 加入了各自階段的任務隊列。第二輪循環首先進入 timers 階段,執行定時器隊列回調,然后 pending callbacks 和 poll 階段沒有任務,因此進入check 階段執行 setImmediate 回調。所以最后輸出為“timeout”、“setImmediate”。當然這里還有種理論上的極端情況,就是第一輪循環結束后耗時很短,導致 setTimeout 的計時還沒結束,此時第二輪循環則會先執行 setImmediate 回調。
再看這下面一段代碼,它只是把上一段代碼放在了一個 I/O 任務回調中,它的輸出將與上一段代碼相反。
const fs = require('fs');
fs.readFile(__filename, (data) => { console.log('readFile'); setTimeout(() => { console.log('timeout');
}, 0); setImmediate(() => { console.log('setImmediate');
});
});// 輸出:readFile、setImmediate、timeout
如上面代碼所示:
第一輪循環沒有需要執行的異步任務隊列;
第二輪循環 timers 等階段都沒有任務,只有 poll 階段有 I/O 回調任務,即輸出“readFile”;
參考前面事件階段的說明,接下來,poll 階段會檢測如果有 setImmediate 的任務隊列則進入 check 階段,否則再進行判斷,如果有定時器任務回調,則回到 timers 階段,所以應該進入 check 階段執行 setImmediate,輸出“setImmediate”;
然后進入最后的 close callbacks 階段,本次循環結束;
最后進行第三輪循環,進入 timers 階段,輸出“timeout”。
所以最終輸出“setImmediate”在“timeout”之前。可見這兩者的執行順序與當前執行的階段有關系。
以上就是“JavaScript事件循環的原理是什么”這篇文章的所有內容,感謝各位的閱讀!相信大家閱讀完這篇文章都有很大的收獲,小編每天都會為大家更新不同的知識,如果還想學習更多的知識,請關注億速云行業資訊頻道。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。