深入理解 JavaScript 事件循環(一)— event loop


 引言

  相信所有學過 JavaScript 都知道它是一門單線程的語言,這也就意味着 JS 無法進行多線程編程,但是 JS 當中卻有着無處不在的異步概念 。在初期許多人會把異步理解成類似多線程的編程模式,其實他們中有着很大的差別,要完全理解異步,就需要了解 JS 的運行核心——事件循環(event loop)。在之前我對事件循環的認識也是一知半解的,直到我看了 Philip Roberts 的演講 What the heck is the event loop anyway?,我才對事件循環有了一個全面的認識,所以我想寫一篇介紹 JS 事件循環的文章,以供大家學習和參考。

 一、為什么會有異步?

  為什么 JS 當中會有異步?我們想象一下,如果我們同步的執行一下代碼會發生什么:

1 $.get(url, function(data) {
2     //do something
3 });

  在我們使用 ajax 進行通信的時候,我們都默認了它是異步的,但是如果我們設置其為同步執行,會發生什么?如果你自己寫一個小的測試程序,將后台代碼延遲5s你會發現瀏覽器會出現阻塞,直到 ajax 響應了之后才會正常運行。這便是異步模式要解決的首要問題,如何使瀏覽器非阻塞的運行任務。想象一下如果我們同步的執行 ajax 請求的話,我們的等待的時間是一個未知數,在網絡通信中可能很快也可能很慢,也可能永遠也不會響應,這也就會導致瀏覽器會阻塞在一個未知的任務上面,這也是我們不希望看到的。所以我們希望有一種方式能夠異步的處理程序,我們並不需要關心一個 ajax 請求會在何時完成,甚至它可以永遠不會響應,我們只需要知道在請求響應后該如何處理,並且在等待響應的這段時間內我們還可以做一些其他的工作。因此,便有了 JavaScript Event Loop。

 二、什么是事件隊列?

  首先,我們先來看一段簡單的代碼:

1 console.log("script start");
2 
3 setTimeout(function () {
4     console.log("setTimeout");
5 }, 1000);
6 
7 console.log("script end");

  你可以在這里查看結果:

   我們可以看到,首先,程序輸出 'script start''script end',在大約1s之后輸出了 'setTimeout' 。該程序的 'script end' 並沒有等待1s之后輸出,而是立即輸出。這是因為 setTimeout 是一個異步的函數。意思也就是說當我們設置一個延遲函數的時候,當前腳本並不會阻塞,它只是會在瀏覽器的事件表中進行記錄,程序會繼續向下執行。當延遲的時間結束之后,事件表會將回調函數添加至事件隊列(task queue)中,事件隊列拿到了任務過后便將任務壓入執行棧(stack)當中,執行棧執行任務,輸出 'setTimeout'

  事件隊列是一個存儲着待執行任務的隊列,其中的任務嚴格按照時間先后順序執行,排在隊頭的任務將會率先執行,而排在隊尾的任務會最后執行。事件隊列每次僅執行一個任務,在該任務執行完畢之后,再執行下一個任務。執行棧則是一個類似於函數調用棧的運行容器,當執行棧為空時,JS 引擎便檢查事件隊列,如果不為空的話,事件隊列便將第一個任務壓入執行棧中運行。

  現在,我們對上面的代碼進行一點修改:

1 console.log("script start");
2 
3 setTimeout(function () {
4     console.log("setTimeout");
5 }, 0);
6 
7 console.log("script end");

  將延遲時間設置為0,看看程序會以何種順序輸出?無論我們設置多少的延遲時間,'setTimeout' 總是會在 'script end' 之后輸出。有些瀏覽器可能會有一個最小延遲時間,有的是 15ms,有的是 10ms,這個在很多書當中都有提到,這可能會給同學們造成一種錯覺:由於程序運行速度很快,並且有最小延遲時間,所以 'setTimeout' 會在 'script end' 之后輸出。現在讓我們在稍微變一下,來消除你的錯覺:

 1 console.log("script start");
 2 
 3 setTimeout(function () {
 4     console.log("setTimeout");
 5 }, 0);
 6 
 7 //具體數字不定,這取決於你的硬件配置和瀏覽器
 8 for(var i = 0; i < 999999999; i ++){
 9     //do something
10 }
11 
12 console.log("script end");

  你可以在這里查看結果:

   可以看出,無論后面我們做了多少延遲性的工作,'setTimeout' 總是會在 'script end' 之后輸出。所以究竟發生了什么?這是因為 setTimeout 的回調函數只是會被添加至事件隊列,而不是立即執行。由於當前的任務沒有執行結束,所以 setTimeout 任務不會執行,直到輸出了 'script end' 之后,當前任務執行完畢,執行棧為空,這時事件隊列才會把 setTimeout 回調函數壓入執行棧執行。

  執行棧則像是函數的調用棧,是一個樹狀的棧:

 三、事件隊列有何作用?

  通過以上的 demo 相信同學們都會對事件隊列和執行棧有了一個基本的認識,那么事件隊列有何作用?最簡單易懂的一點就是之前我們所提到的異步問題。由於 JS 是單線程的,同步執行任務會造成瀏覽器的阻塞,所以我們將 JS 分成一個又一個的任務,通過不停的循環來執行事件隊列中的任務。這就使得當我們掛起某一個任務的時候可以去做一些其他的事情,而不需要等待這個任務執行完畢。所以事件循環的運行機制大致分為以下步驟:

  1.   檢查事件隊列是否為空,如果為空,則繼續檢查;如不為空,則執行 2;
  2.   取出事件隊列的首部,壓入執行棧;
  3.        執行任務;
  4.        檢查執行棧,如果執行棧為空,則跳回第 1 步;如不為空,則繼續檢查;

  然而目前為止我們討論的僅僅是 JS 引擎如何執行 JS 代碼,現在我們結合 Web APIs 來討論事件循環在當中扮演的角色。

  在開始我們討論過 ajax 技術的異步性和同步性,通過事件循環機制,我們則不需要等待 ajax 響應之后再進行工作。我們則是設置一個回調函數,將 ajax 請求掛起,然后繼續執行后面的代碼,至於請求何時響應,對我們的程序不會有影響,甚至它可能永遠也不響應,也不會使瀏覽器阻塞。而當響應成功了以后,瀏覽器的事件表則會將回調函數添加至事件隊列中等待執行。事件監聽器的回調函數也是一個任務,當我們注冊了一個事件監聽器時,瀏覽器事件表會進行登記,當我們觸發事件時,事件表便將回調函數添加至事件隊列當中。

  我們知道 DOM 操作會觸發瀏覽器對文檔進行渲染,如修改排版規則,修改背景顏色等等,那么這類操作是如何在瀏覽器當中奏效的?至此我們已經知道了事件循環是如何執行的,事件循環器會不停的檢查事件隊列,如果不為空,則取出隊首壓入執行棧執行。當一個任務執行完畢之后,事件循環器又會繼續不停的檢查事件隊列,不過在這間,瀏覽器會對頁面進行渲染。這就保證了用戶在瀏覽頁面的時候不會出現頁面阻塞的情況,這也使 JS 動畫成為可能, jQuery 動畫在底層均是使用 setTimeout 和 setInterval 來進行實現。想象一下如果我們同步的執行動畫,那么我們不會看見任何漸變的效果,瀏覽器會在任務執行結束之后渲染窗口。反之我們使用異步的方法,瀏覽器會在每一個任務執行結束之后渲染窗口,這樣我們就能看見動畫的漸變效果了。

  考慮如下兩種遍歷方式:

 1 var arr = new Array(999);
 2 arr.fill(1);
 3 function asyncForEach(array, handler){
 4     var t = setInterval(function () {
 5         if(array.length === 0){
 6             clearInterval(t);
 7         }else {
 8             handler(arr.shift());
 9         }
10     }, 0);
11 }
12 
13 //異步遍歷
14 asyncForEach(arr, function (value) {
15     console.log(value);
16 });
17 
18 //同步遍歷
19 arr.forEach(function (value, index, arr) {
20     console.log(value);
21 });

  經過測試,我們可以看出,采用同步遍歷的方法,當數組長度上升到3位數的時候,便會出現阻塞,但是異步遍歷卻不會出現阻塞現象(除非數組長度非常大,那是因為計算機的內存空間不足)。這是因為同步遍歷方法是一個單獨的任務,這個任務會將所有的數組元素遍歷一遍,然后才會開始下一個任務。而異步遍歷的方法將每一次遍歷拆分成一個單獨的任務,一個任務只遍歷一個數組元素,所以在每個任務之間,我們的瀏覽器可以進行渲染,所以我們不會看見阻塞的情況。下面這個 demo 演示了在異步遍歷前后發生的事情:

 總結

  現在,相信你已經認識了 JavaScript 的真實面目了吧。 JavaScript 是一門單線程的語言,但是其事件循環的特性使得我們可以異步的執行程序。這些異步的程序也就是一個又一個獨立的任務,這些任務包括了 setTimeout、setInterval、ajax、eventListener 等等。關於事件循環,我們需要記住以下幾點:

  •   事件隊列嚴格按照時間先后順序將任務壓入執行棧執行;
  •        當執行棧為空時,瀏覽器會一直不停的檢查事件隊列,如果不為空,則取出第一個任務;
  •        在每一個任務結束之后,瀏覽器會對頁面進行渲染;

  本文 demo 放在 jsfiddle 上,如需轉載,注明下出處就好了。若您發現本文有所紕漏,歡迎在評論區指出。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM