通常狀況下,僅從代碼上看只要不出現死循環,是不會出現堆棧調用溢出的。可是某些狀況下列外,好比下面這段代碼:node
1 var a = 99; 2 function b (){ 3 a --; 4 if (a > 0){ 5 b(); 6 } else { 7 console.info(a); 8 } 9 } 10 b(); 11 => 0
這並非死循環,當變量 a逐漸減小到0時,遞歸就終止了。乍一看是不會出現任何問題的,可是若是咱們把 a增長到一個較大的數值,就會出現問題:web
如圖所示,一個範圍錯誤的異常拋了出來,咱們被告知"超過了最大棧調用大小",哈哈,若是業務代碼裏出現了針對大量數據的遞歸,後果可想而知。全部咱們有必要知道js調用棧的一些特色。chrome
針對示例中 b函數來講,它的內部應用了外部做用域中的 a變量,造成了閉包,只要符合條件,它會一直被遞歸調用。而當b函數每一次被調用都會有新的閉包產生,爲了記錄對外部做用域中的變量引用,上一次因函數調用產生的棧幀不會從棧頂出去,致使棧中的棧幀超過了容許的數量而拋出棧調用溢出的異常。咱們在函數內部能從arguments獲取調用時的入參以及函數體自己(callee),以及調用者(caller),都是創建在該次函數調用產生的棧幀被記錄的基礎上的。而js引擎(或者是其餘計算機語言的解釋器)設計這種限制的目的就是在於要控制程序對內存資源的使用量,若是無此限制,一個錯誤的代碼就足以讓計算機奔潰。在較爲新版的Chrom中,調用棧深度在13000次左右,FireFox在60000次左右,Node.js在10000次左右。因版本不一樣可能限制不一樣,這個能夠自行測試。promise
針對js遞歸中容易出現棧調用溢出問題,是有解決辦法的。瀏覽器
利用js事件循環機制來處理該問題babel
js最初就是被做爲瀏覽器端語言而開發的,它可以和處理DOM的引擎進行交互,它處於和處理HTML、CSS、layout等事務的線程中也就是主線程中。以chrome瀏覽器多進程架構爲例,一個web頁面實際就是由主線程和排版線程(或者叫合成線程)相互協做來完成一個頁面的渲染和更新的,這兩個線程處於渲染進程內部,由渲染進程進行調度。固然對於整個瀏覽器來講,還存在有其餘的進程,多個進程之間的協做完成整個瀏覽器的全部工做,包括多tab展現多個頁面。簡單的說來就是主線程解析和處理上層語言的代碼,解析出最終的位圖(像素陣列圖像)並交給排版線程,排版線程根據主線程輸出的結果,調用操做系統底層圖形接口來計算和繪製頁面到顯示器上(主要是涉及到與GPU有關的事務)。但爲了表現的一致性,在主線程內部,JS和DOM引擎的交互不是並行工做的。若是它們之間的操做是非阻塞的話,須要很是複雜的鎖機制來避免同時對一個元素進行操做而致使出錯,因此同時多個對DOM的修改之間必須是互斥的。主線程與排版線程之間是並行工做的,排版線程不會一直等待主線程的位圖反饋,無反饋就直接渲染空白,會出現掉幀、白屏等現象。主線程的特色確保了頁面的表現一致性,但卻帶來了另一個問題:阻塞。閉包
試想若是我在作一個xhr請求,在請求沒回來以前,按照單線程阻塞的特色,頁面是沒有任何反應的,全部瀏覽器內部事務和用戶操做都阻塞了,動畫所有中止,用戶的點擊事件也無法響應,這簡直就是噩夢。爲了不這個問題,js在設計之初就擁有一個 Event Loop 事件輪詢(循環)機制來支持異步回調,特備是I/O有關的異步回調。架構
由上圖能夠看出在主線程以外其實還維護了一個隊列,整個過程由上到下。咱們使用setTimeout等異步定時器操做的函數都被推入了任務隊列中,而不是在主線程裏直接被運行了。當主線程的C過程當中的同步任務被執行完後,在此刻主線程中的任務都被執行完,事件輪詢會在任務隊列中去查看是有任務須要執行的事件。這個事件的產生就是由任務隊列中的任務執行完成後生成出的一個標記。若是任務隊列中有須要執行的事件,那麼將這個事件所對應的任務推回主線程中進行執行,如上圖 A函數,A函數執行完成後或者在執行的過程當中,主線程執行棧中又被壓入了一個D過程的同步任務,在A函數執行後就開始執行D任務。固然數值都只是打個比方,不可能通過100毫秒、200毫秒就正好能夠見縫插針。總之事件輪詢機制就是不停地定時查看主線程是否空閒,若是空閒,就去隊列中找事情到主線中去作。也就是說異步的函數調用是不會阻塞的,除非是主線程同步任務本身阻塞了,好比:異步
在瀏覽器中彈出alert,若是不點擊肯定,console的內容是永遠不會出現的。由於alert(1)是在主線程中調用的,若是用戶沒有在瀏覽器上有任何點擊彈出框肯定按鈕的動做,該同步任務一直在執行棧中處於掛起狀態,主線程是一直阻塞着的,且沒法進行下一個同步任務的執行。即使事件輪詢機制發現了事件隊列中有任務到了須要執行的時間點,該任務的執行也會排在主線程阻塞完成以後。socket
以上只是一個對異步循環機制隊列的簡單描述,其實隊列還細分爲宏任務(macro task)和微任務(micro task)隊列,微任務隊列的優先級高於宏任務,低於主線程執行棧的任務。相似於setTimeout、setInterval等都歸屬於宏任務類型,而傳入promise對象的then方法的函數歸屬於微任務類型,當同時存在時,調用then時傳入的回調函數的執行優先級高於調用setTimeout等方法是傳入的回調函數。
以上算是對js事件輪詢機制有個初步的描述,那麼利用這一機制怎麼來解決遞歸中可能會出現的調用棧溢出狀況呢?
經過上面的函數調用棧咱們已經知道每次函數的調用若是有對外層內容的引用或依賴,本次函數調用時在調用棧中建立調用幀都會被保留。若是達到的最大調用大小尚未被清除,那麼就會拋出異常。可是咱們能夠在每次調用的時候將對函數的遞歸調用放到異步方法中去,好比經過setTimeout方法,強行將函數的同步調用放到主線程之外的任務隊列中,把主線程對函數調用的控制權交由更上一層的事件輪詢機制來處理。以前代碼片斷能夠修改成:
1 var a = 9999; 2 function b (){ 3 a --; 4 if (a > 0){ 5 setTimeout(b, 4); 6 } else { 7 console.info(a); 8 } 9 } 10 b();
大概等了一下子,控制檯輸出了0;實際測試中即使將a修改成99999,只要時間等的足夠久也是能看到控制檯打出東西的。
經過setTimeout異步函數來調用b時,上一次當b函數被調用完成後,主線程的執行棧會清除掉該次調用棧幀,由於到setTimeout這裏的時候,主線程執行棧已經知道b在主線的調用已經結束了,不須要爲它保存任何記錄,它被推入了主線程外的隊列中去了,控制權由主線程交到了事件輪詢機制手裏。既然調用棧幀每一次都會被清除,天然也不會出現調用棧達到最大值的異常了。當定時器到點時,就會在任務隊列中產生一個事件,事件輪詢機制下一次輪詢的時候,會在任務隊列中發現這個事件,就會知道b函數如今能夠被拿回到主線程中執行了。
同時這也解釋了爲何setTimeout和setIntervel異步調用的函數內容的this指向的是window對象,由於即使他們是處於某個對象的方法中,他們的調用也就是事件輪詢機制決定的,並非主線程一手操控,和他們在被書寫時候處於哪一個對象內部並無直接的關係。實際上是js引擎(對於頁面做用域來講也就是window對象)調用了它們,而不是代碼上的a對象調用的,全部this也天然不會指向a對象:
除了這個方法能夠處理遞歸調用可能存在的調用棧溢出問題,還有尾調用優化也能解決,在支持ES6的現代瀏覽器,只要函數是尾調用並開啓 "use strict" 嚴格模式,就會在執行的時候被優化成循環方式來替換函數遞歸調用進行優化,避免巨量的調用幀出現且不能被清空的狀況發生。可是,ES標準中最初有針對編譯器提出過尾調用優化的要求,但後來這個標準被廢棄了。
在babel6如下版本,一代源代碼符合尾調用,會被轉譯成while循環來避免因遞歸的深層級而引發的爆棧,但在後續babel6版本中被取消了,多是由於while性能不佳也不被嚴格模式支持的緣由,或這是ES標準變更上的緣由,後續可能會有更優方案提供。
Node.js中的事件循環機制
在Node.js中有一套基於服務端應用用途的事件循環機制,對於JavaScript語言來講,setInterval、setTimeout做爲語言標準是必定支持的外,setImmediate和process.nextTick是Node.js獨有的,而Promise的異步回調是ES2015標準加入的,在現代瀏覽器中也都支持。這些方法或新標準在較爲新的Node.js版本中都能徹底支持。Node.js引入libuv庫做爲內部事件循環機制的管理器。先拋開I/O操做,看一下Node.js中的回調函數是怎麼被處理的:
// a.js setTimeout(() => console.log(1)); setImmediate(() => console.log(2)); process.nextTick(() => console.log(3)); Promise.resolve().then(() => console.log(4));
$ node a.js
4
3
1
2
這三句話都是以異步回調的模式運行,不是直接在主線程中執行的。能夠看到3和4先被打印出來,1和2跟隨其後,爲啥後執行的反而先出來呢?在Node.js中異步任務能夠分紅兩種:追加在本輪事件循環的異步任務和追加在次輪事件循環的異步任務。使用過Vue.js的同窗必定對nextTick這個東西不陌生,它能夠傳入一個下一次渲染時再調用的函數,這個函數在下一次渲染的時候再執行。但在Node.js中,經過上面代碼的執行nextTick其實追加到本輪循環執行後的,是全部異步任務裏面最快執行的,哈哈,它的名稱彷佛產生了誤解。對於Promise來講,根據ES2015的語言標準,會進入異步隊列中的microTask隊列,並追加在nextTick以後,也屬於本輪事件循環的異步任務。全部的微任務隊列在nextTick隊列執行完成後執行,nextTick隊列在同步任務執行完成後執行。
加入I/O操做後,事件循環機制有6個階段:
1. timers 對setTimeout和setInterval的處理
2. I/O callbacks 除timers和nextTick等之外的回調函數
3. idle, prepare libuv庫內部執行
4. poll 輪詢還未返回的I/O操做事件
5. check 執行nextTick也就是setImmediate回調函數
6. close callbacks 執行關閉請求的回調函數,好比socket.on('close', xxx)
每一個階段都有一個先進先出的回調函數隊列。只有一個階段的回調函數隊列清空了,該執行的回調函數都執行了,事件循環纔會進入下一個階段。