前面的話
本文將詳細介紹javascript中的事件循環event-loop
線程
javascript是單線程的語言,也就是說,同一個時間只能做一件事。而這個單線程的特性,與它的用途有關,作為瀏覽器腳本語言,JavaScript的主要用途是與用戶互動,以及操作DOM。這決定了它只能是單線程,否則會帶來很復雜的同步問題。比如,假定JavaScript同時有兩個線程,一個線程在某個DOM節點上添加內容,另一個線程刪除了這個節點,這時瀏覽器應該以哪個線程為准?
為了利用多核CPU的計算能力,HTML5提出Web Worker標准,允許JavaScript腳本創建多個線程,但是子線程完全受主線程控制,且不得操作DOM。所以,這個新標准並沒有改變JavaScript單線程的本質
【排隊】
單線程就意味着,所有任務需要排隊,前一個任務結束,才會執行后一個任務。如果前一個任務耗時很長,后一個任務就不得不一直等着
var i, t = Date.now()
for (i = 0; i < 100000000; i++) {} console.log(Date.now() - t) // 238
像上面這樣,如果排隊是因為計算量大,CPU忙不過來,倒也算了
但是,如果是網絡請求就不合適。因為一個網絡請求的資源什么時候返回是不可預知的,這種情況再排隊等待就不明智了
同步和異步
於是,任務分為同步任務和異步任務
【同步】
如果在函數返回的時候,調用者就能夠得到預期結果(即拿到了預期的返回值或者看到了預期的效果),那么這個函數就是同步的
Math.sqrt(2); console.log('Hi');
第一個函數返回時,就拿到了預期的返回值:2的平方根;第二個函數返回時,就看到了預期的效果:在控制台打印了一個字符串
所以這兩個函數都是同步的
【異步】
如果在函數返回的時候,調用者還不能夠得到預期結果,而是需要在將來通過一定的手段得到,那么這個函數就是異步的
fs.readFile('foo.txt', 'utf8', function(err, data) { console.log(data); });
在上面的代碼中,我們希望通過fs.readFile
函數讀取文件foo.txt中的內容,並打印出來。但是在fs.readFile
函數返回時,我們期望的結果並不會發生,而是要等到文件全部讀取完成之后。如果文件很大的話可能要很長時間
所以,fs.readFile函數是異步的
正是由於JavaScript是單線程的,而異步容易實現非阻塞,所以在JavaScript中對於耗時的操作或者時間不確定的操作,使用異步就成了必然的選擇
異步詳解
從上文可以看出,異步函數
實際上很快就調用完成了。但是后面還有執行異步操作、通知主線程、主線程調用回調函數等很多步驟。我們把整個過程叫做異步過程
。異步函數的調用在整個異步過程中,只是一小部分
一個異步過程通常是這樣的:主線程發起一個異步請求,異步任務接收請求並告知主線程已收到(異步函數返回);主線程可以繼續執行后面的代碼,同時異步操作開始執行;執行完成后通知主線程;主線程收到通知后,執行一定的動作(調用回調函數)
因此,一個異步過程包括兩個要素:注冊函數和回調函數,其中注冊函數用來發起異步過程,回調函數用來處理結果
下面的代碼中,其中的setTimeout就是異步過程的發起函數,fn是回調函數
setTimeout(fn, 1000);
有一個很重要的問題,如何才算是異步操作執行完成呢?對於不同類型的異步任務,操作完成的標准不同
【異步類型】
一般而言,異步任務有以下三種類型
1、普通事件,如click、resize等
2、資源加載,如load、error等
3、定時器,包括setInterval、setTimeout等
下面對這三種類型分別舉例說明,下面代碼中,鼠標點擊div時,就代表任務執行完成了
div.onclick = () => { console.log('click') }
下面代碼中,XHR對象的readyState值為4,即已經接收到全部響應數據了,代表任務執行完成
xhr.onreadystatechange = function(){ if(xhr.readyState == 4){ if(xhr.status == 200){ //實際操作 result.innerHTML += xhr.responseText; } } }
下面代碼中,過1s后,代表任務執行完成
setTimeout(() => { console.log('timeout') },1000)
對於同步任務來說,按順序執行即可;但是,對於異步任務,各任務執行的時間長短不同,執行完成的時間點也不同,主線程如何調控異步任務呢?這就用到了消息隊列
【消息隊列】
有些文章把消息隊列稱為任務隊列,或者叫事件隊列,總之是和異步任務相關的隊列
可以確定的是,它是隊列這種先入先出的數據結構,和排隊是類似的,哪個異步操作完成的早,就排在前面。不論異步操作何時開始執行,只要異步操作執行完成,就可以到消息隊列中排隊
這樣,主線程在空閑的時候,就可以從消息隊列中獲取消息並執行
消息隊列中放的消息具體是什么東西?消息的具體結構當然跟具體的實現有關。但是為了簡單起見,可以認為:消息就是注冊異步任務時添加的回調函數。
可視化描述
人們把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 運行時包含了一個待處理的消息隊列。每一個消息都與一個函數相關聯。當棧擁有足夠內存時,從隊列中取出一個消息進行處理。這個處理過程包含了調用與這個消息相關聯的函數(以及因而創建了一個初始堆棧幀)。當棧再次為空的時候,也就意味着消息處理結束
事件循環
下面來詳細介紹事件循環。下圖中,主線程運行的時候,產生堆和棧,棧中的代碼調用各種外部API,異步操作執行完成后,就在消息隊列中排隊。只要棧中的代碼執行完畢,主線程就會去讀取消息隊列,依次執行那些異步任務所對應的回調函數

詳細步驟如下:
1、所有同步任務都在主線程上執行,形成一個執行棧
2、主線程之外,還存在一個"消息隊列"。只要異步操作執行完成,就到消息隊列中排隊
3、一旦執行棧中的所有同步任務執行完畢,系統就會按次序讀取消息隊列中的異步任務,於是被讀取的異步任務結束等待狀態,進入執行棧,開始執行
4、主線程不斷重復上面的第三步
【循環】
從代碼執行順序的角度來看,程序最開始是按代碼順序執行代碼的,遇到同步任務,立刻執行;遇到異步任務,則只是調用異步函數發起異步請求。此時,異步任務開始執行異步操作,執行完成后到消息隊列中排隊。程序按照代碼順序執行完畢后,查詢消息隊列中是否有等待的消息。如果有,則按照次序從消息隊列中把消息放到執行棧中執行。執行完畢后,再從消息隊列中獲取消息,再執行,不斷重復。
由於主線程不斷的重復獲得消息、執行消息、再取消息、再執行。所以,這種機制被稱為事件循環
用代碼表示大概是這樣:
while (queue.waitForMessage()) {
queue.processNextMessage();
}
如果當前沒有任何消息queue.waitForMessage
會等待同步消息到達
【事件】
為什么叫事件循環?而不叫任務循環或消息循環。究其原因是消息隊列中的每條消息實際上都對應着一個事件
DOM操作對應的是DOM事件,資源加載操作對應的是加載事件,而定時器操作可以看做對應一個“時間到了”的事件
實例
下面以一個實例來解釋事件循環機制
console.log(1) div.onclick = () => {console.log('click')} console.log(2) setTimeout(() => {console.log('timeout')},1000)
1、執行第一行代碼,第一行是一個同步任務,控制台顯示1
2、執行第二行代碼,第二行是一個異步任務,發起異步請求,可以在任意時刻執行鼠標點擊的異步操作
3、執行第三行代碼,第三行是一個同步任務,控制台顯示2
4、執行第四行代碼,第四行是一個異步任務,發起異步請求,1s后執行定時器任務
5、假設從執行第四行代碼的1s內,執行了鼠標點擊,則鼠標任務在消息隊列中排到首位
6、從執行第四行代碼1s后,定時器任務到消息隊列中排到第二位
7、現在同步任務已經執行完畢,則從消息隊列中按照次序把異步任務放到執行棧中執行
8、則控制台依次顯示'click‘、'timeout'
9、過了一段時間后,又執行了一次鼠標點擊,由於消息隊列中已經空了,則鼠標任務在消息隊列中排到首位
10、同步任務執行完畢后,再從消息隊列中按照次序把異步任務放到執行棧中執行
11、 則控制台顯示'click'
【異步過程】
下面以一個實例來解釋一次完整的異步過程
div.onclick = function fn(){console.log('click')}
1、主線程通過調用異步函數div.onclick發起異步請求
2、在某一時刻,執行異步操作,即鼠標點擊
3、接着,回調函數fn到消息隊列中排隊
4、主線程從消息隊列中讀取fn到執行棧中
5、然后在執行棧中執行fn里面的代碼console.log('click')
6、於是,控制台顯示'click'
同步變異步
每一個消息完整的執行后,其它消息才會被執行。這點提供了一些優秀的特性,包括每當一個函數運行時,它就不能被搶占,並且在其他代碼運行之前完全運行
這個模型的一個缺點在於當一個消息需要太長時間才能完成,Web應用無法處理用戶的交互,例如點擊或滾動
於是,對於這種情況的常見優化是同步變異步
一個例子是創建WebQQ的QQ好友列表。列表中通常會有成百上千個好友,如果一個好友用一個節點來表示,在頁面中渲染這個列表的時候,可能要一次性往頁面中創建成百上千個節點
在短時間內往頁面中大量添加DOM節點顯然也會讓瀏覽器吃不消,看到的結果往往就是瀏覽器的卡頓甚至假死。代碼如下:
var ary = []; for ( var i = 1; i <= 1000; i++ ){ ary.push( i ); // 假設 ary 裝載了 1000 個好友的數據 }; var renderFriendList = function( data ){ for ( var i = 0, l = data.length; i < l; i++ ){ var div = document.createElement( 'div' ); div.innerHTML = i; document.body.appendChild( div ); } }; renderFriendList( ary );
這個問題的解決方案之一是數組分塊技術,下面的timeChunk函數讓創建節點的工作分批進行,比如把1秒鍾創建1000個節點,改為每隔200毫秒創建8個節點
function chunk(array,process,context){ setTimeout(function(){ //取出下一個條目並處理 var item = array.shift(); process.call(context,item); //若還有條目,再設置另一個定時器 if(array.length > 0){ setTimeout(arguments.callee,100); } },100); }
var data = [1,2,3,4,5,6,7,8,9,0]; function printValue(item){ var div = document.getElementById('myDiv'); div.innerHTML += item + '<br>'; } chunk(data.concat(),printValue);
數組分塊的重要性在於它可以將多個項目的處理在消息隊列上分開,在每個項目處理之后,給予其他的異步任務的執行機會,這樣就可能避免長時間運行腳本的錯誤。一旦某個函數需要花50ms以上的時間完成,那么最好看看能否將任務分割為一系列可以使用定時器的小任務