在一次開發任務中,須要實現以下一個餅狀圖動畫,基於canvas進行繪圖,但因爲對於JS運行環境中異步機制的不瞭解,因此遇到了一個棘手的問題,始終沒法解決,以後在與同事交流以後才恍然大悟。問題的根節在於經典的JS定時器異步問題,因此在解決問題以後,又經過了大量的資料閱讀擴展和一段時間的實戰總結,如今對JS運行環境中異步機制作一個較爲深刻的分析。html
上圖中爲最終想要實現的效果,使得各扇形部分能夠同時畫出並閉合圓形。點擊此處查看代碼清單。以前遇到的問題是沒有將myLoop做爲一個函數抽離出來,而將其中的全部邏輯,包括定時器都寫在了for循環中,這樣雖然扇形角度、哨兵變量等的計算均正確,但圓形始終沒法閉合,非常鬱悶。這裏我只是想借此問題來引入JS運行環境中對於異步機制理解的重要性,大可沒必要關心canvas畫圖的實現過程,讓你們明白對異步的理解會牽扯到業務邏輯執行的準確性,並不是只是用於浮於紙面的面試題之上。至於爲何將定時器的邏輯放在一個函數中就執行正常,而直接寫入for循環就沒法達到預期,看過下文的詳細分析後,這個問題便會迎刃而解。node
關於異步的深刻,這裏基於現有的知識水平作儘量詳盡準確的分析。你們能夠從一篇博客進一步瞭解牛人之間對於異步理解的爭論。一位是技術博客紅人阮一峯老師,一位是國內Node技術的開山鼻祖樸靈老師,都是我持續關注的兩位偶像。事情發生的比較早了,這裏只給出一個文章連接,其中在阮老師的博文中附帶了大量樸靈老師的批註,讀過以後定會受益不淺,也會激發出你對技術外的一些思考。面試
首先來講明同步與異步兩個概念。ajax
f1() f2()
對於JavaScript語言的執行方式,執行環境會支持兩種模式,一種是同步執行,一種是異步執行。如上面兩個方法,同步執行就是調用f1以後,等待返回結果,再執行f2。異步是調用f1後,經過一系列其餘的操做才能夠獲得預期的結果,好比網絡IO、磁盤IO等,在線程執行這些其餘操做的同時,程序還能夠往下執行,繼續調用f2,不用等待f1的結果返回再執行f2。編程
咱們知道,大部分的腳本和編程語言都是同步編程,開發者對於同步編程的執行邏輯也比較容易理解。那麼爲何對於JS的執行要常常用到異步編程,這應該要追溯到最初JS適用的宿主環境--瀏覽器。canvas
因爲用於瀏覽器,因此操做DOM的JS只能使用單線程,不然沒法保證DOM操做的安全性(好比一個線程將另外一個線程正在使用的某個DOM刪掉)。又由於使用單線程,同步執行代碼的話,若是遇到耗時較長的操做,那麼瀏覽器將會長時間失去響應,用戶體驗及其很差。但若是將耗時較長的任務,好比ajax請求異步執行,那麼客戶端的渲染便不會受到耗時任務的阻塞。vim
對於服務器端,JS異步執行更爲重要,由於執行環境是單線程的,若是同步執行全部併發請求,那麼對於客戶端的響應將會極其遲鈍,服務器性能急劇降低,這時必須使用異步模式來處理大量併發請求,不像Java、PHP等語言是經過多線程來解決併發問題。這點在如今高併發司空見慣的網絡環境中,反而成爲了JS的優點,使得Node在短期內進入主流視野,成爲DIRT應用1的最佳解決方案。瀏覽器
在說實現異步的機制以前,首先須要搞清楚兩個概念,分別是JavaScript的執行引擎和執行環境。咱們常說Google的V8虛擬機即是JavaScript的執行引擎,除此以外Safari的JavaScript Core、FireFox的SpiderMonckey都屬於Engine。而上述的瀏覽器和Node等便屬於JavaScript的執行環境,是Runtime。前者Engine是去實現ECMAScript標準,後者Runtime是去實現異步的具體機制。因此咱們今天講的JS異步機制都是在說JS執行環境的異步機制,與V8這樣的執行引擎並沒有關係,主要是由各大瀏覽器廠商去作實現。安全
關於實現異步的方式,有咱們接下來要詳細介紹的Event Loop,還有輪詢、事件等。所謂輪詢,就是你在收銀臺付款以後,不停的問服務員你的飯菜作好了嗎。所謂事件,就是你在付款以後,不用不停的問服務員,服務員在作好飯菜以後會主動告訴你。而大部分的執行環境都是經過Event Loop去實現異步機制,因此下面重點來說解Event Loop。服務器
Event Loop的實現邏輯以下圖。每當程序啓動後,內存會被分爲堆(heap)和棧(stack)兩部分,其中棧中即是主線程的執行邏輯所需內存,咱們根據這塊內存的特殊做用,抽象的將其叫作執行棧。在棧中的代碼會調用各類WebAPI,好比對DOM的操做,ajax請求,建立定時器等。這些操做會產生一些事件,而事件又會關聯相應的handle(也就是註冊時的callback),將須要執行的handle按照隊列的結構放入callback queue(event queue)中。當執行棧中的代碼執行完畢後,主線程會讀取callback queue,依次執行其中的回調函數,而後進入下一輪的事件循環,執行清空新產生的事件回調函數。因而可知,在執行棧中的代碼老是在callback queue以前執行。
圖片轉引自Philip Roberts的演講《Help, I'm stuck in an event-loop》
setTimeout()和setInterval()兩個定時器中回調的執行邏輯即是典型的Event Loop機制。類似的,程序在跑完執行棧中的代碼後,事件循環會不停的檢查系統時間是否到達預設的時間點,每當到達預設的時間點時,就會產生一個timeout事件,並將其放入callback queue,等待下輪Event loop執行。但在實際應用中,有可能執行棧中的代碼耗時過長,這樣在執行完執行棧中的代碼後,再去執行callback queue中由setTimeout()產生的回調時就不能保證在預期的時間點執行,因此JS中的定時器並不總能保證其精準性。而在詳細瞭解其特性原理後,咱們能夠在編程應用層面作一些優化,儘可能使定時器中回調函數的執行時間點與咱們預期保持一致。因爲setTimeout()與setInterval()在本質上是一致的,因此在下面的實例分析一節中咱們將會以setTimeout()來作關於異步機制的分析。
關於異步編程個人理解是,在JS執行環境所提供的異步機制之上,在應用編碼層面上實現總體流程控制的異步風格。具體地,咱們能夠用相似setTimeout()中的回調函數的形式進行異步編程,或者用相似事件驅動的發佈/訂閱模式,或者用ES6爲咱們提供的異步編程的統一接口Promise實現,再或者能夠嘗試最新最酷的ES7中Async/Await方案,還有一些像Node社區提供的異步流控庫Step等。這裏只是爲你們明確異步編程這個概念範疇,具體用法再也不深刻。
這一節中我將會舉出多例來分析,請你們結合上述理論細細體會JS中的同步與異步。首先咱們從一個經典的JS異步面試題開始,而後逐漸深刻。
for (var i = 0; i < 5; i++) { setTimeout(function() { console.log(new Date, i); }, 1000); } console.log(new Date, i);
上述代碼片斷的運行結果應該是,先當即輸出一個5,而後在1秒之後同時輸出五個5。程序開始執行後,首先執行執行棧中的同步代碼,幾乎同時建立了5個定時器,而後繼續執行第7行的同步代碼。這樣,首先在控制檯輸出一個5,而後在1s之後,5個定時器同時產生5個timeout事件放入callback queue,Event loop依次執行隊列中的回調函數,這裏由於閉包的特性,每個定時器的回調都與其定義上下文,for循環中的i變量作了綁定,而i的值已變爲5,因此同時輸出五個5。
若是如今提出一個新需求,要求程序運行後,先當即輸出一個5,而後在1s之後同時輸出0,1,2,3,4,如何改造上述代碼?
//方法一 for (var i = 0; i < 5; i++) { (function(j) { setTimeout(function() { console.log(new Date, j); }, 1000); })(i); } console.log(new Date, i); //方法二 function output (i) { setTimeout(function() { console.log(new Date, i); }, 1000); }; for (var i = 0; i < 5; i++) { output(i); } console.log(new Date, i);
上面給出的兩種方法其實都是一種思路,都是利用JS中,函數做用域做爲一個獨立的做用域,來保存一個局部的上下文環境,並經過閉包的特性使其與setTimeout中的回調函數作綁定。只不過第一種方法是利用IIFE2來實現,第二種方法是經過定義一個函數,再來逐個調用實現。看到這裏,應該想到對於篇首問題背景一節中所提到的問題便與此處一模一樣。
接下來咱們進一步深刻,提出一個新的需求。如何在代碼執行時,當即輸出 0,以後每隔1s依次輸出 1,2,3,4,循環結束後在大概第5秒的時候輸出5?
由於前邊每隔1s輸出的0,1,2,3,4是五個定時器輸出的,也就是五個異步操做,那麼咱們是否是能夠把此次的需求抽象爲:在一系列異步操做完成(每次循環都產生了 1 個異步操做)以後,再作其餘的事情。如今熟悉ES6的同窗應該想到了Promise。
const tasks = []; // 這裏存放異步操做的 Promise const output = (i) => new Promise((resolve) => { setTimeout(() => { console.log(new Date, i); resolve(); }, 1000 * i); }); // 生成所有的異步操做 for (var i = 0; i < 5; i++) { tasks.push(output(i)); } // 異步操做完成以後,輸出最後的 i Promise.all(tasks).then(() => { setTimeout(() => { console.log(new Date, i); }, 1000); });
若是你熟悉ES7中的Async/Await,那麼也能夠嘗試用這種方案解決。
// 模擬其餘語言中的 sleep,實際上能夠是任何異步操做 const sleep = (timeountMS) => new Promise((resolve) => { setTimeout(resolve, timeountMS); }); (async () => { // 聲明即執行的 async 函數表達式 for (var i = 0; i < 5; i++) { await sleep(1000); console.log(new Date, i); } await sleep(1000); console.log(new Date, i); })();
這裏須要着重注意的是瀏覽器對Async/Await標準的支持,若是你的瀏覽器不在如下所支持版本當中,那麼能夠升級瀏覽器或使用babel轉譯處理。
能把上邊這一系列的實例理解到位,相信對JS中異步的這個概念會一些新的體會。下面這個實例會更加細化的考察一下異步代碼中回調的執行時機。
let a = new Promise( function(resolve, reject) { console.log(1) setTimeout(() => console.log(2), 0) console.log(3) console.log(4) resolve(true) } ) a.then(v => { console.log(8) }) let b = new Promise( function() { console.log(5) setTimeout(() => console.log(6), 0) } ) console.log(7)
這裏首先來明確一點,Promise是ES6中爲異步編程所提供的一套API標準,其自己是同步的。因此咱們在new一個Promise對象的時候,其所執行的構造器中的邏輯是同步的。由此得知,上述代碼片斷先從上到下依次執行同步代碼,輸出1,3,4,5,7。而後是先執行then中的異步代碼仍是先執行setTimeout中的回調代碼?這裏須要記住前者要比後者先進入執行棧執行,因此後邊輸出8,2,6。這是由於當即resolved的Promise是在本輪事件循環的末尾執行,相似於node中的process.nextTick方法,它能夠在當前"執行棧"的尾部,下一次Event Loop(主線程讀取"任務隊列")以前,觸發回調函數。setTimeout(fn, 0)則是在當前"任務隊列"的尾部添加事件,也就是說,它指定的任務老是在下一輪次Event Loop時執行,這與node中的setImmediate方法很像。
最後咱們來講一個關於setInterval優化的例子。咱們知道setTimeout中的回調觸發是不許確的,主要緣由是因爲在須要執行回調時,可能執行棧中的代碼尚未執行完,沒法將CPU資源及時的調度給callback queue中的回調執行。而setInterval也會存在一些問題,好比時間間隔可能會跳過,
時間間隔可能小於定時器設定的時間。發生這類狀況其實也是因爲其餘的程序佔用長時間的CPU時間片引發,如下面代碼片斷爲例:
function click() { // code block1... setInterval(function() { // process ... }, 200); // code block2 ... }
若是process中的代碼執行時間過長,佔用了超過400ms,那麼此時JS執行環境就會跳過中間一次時間間隔,由於callback queue中只容許有一份process代碼存在,因此也會產生觸發時機不精準的狀況。
爲了不這種狀況的出現,咱們能夠利用遞歸的方式進行優化處理,如下提供兩種寫法,可是建議使用第一種寫法。由於第二種寫法中,在嚴格模式下,第5版 ECMAScript (ES5) 禁止使用 arguments.callee()。當一個函數必須調用自身的時候, 避免使用 arguments.callee(), 經過要麼給函數表達式一個名字,要麼使用一個函數聲明參見MDN解釋
// 寫法一 setTimeout(function bar (){ // processing foo = setTimeout(bar, 1000); }, 1000); // 寫法二 setTimeout(function(){ // processing foo = setTimeout(arguments.callee, interval); }, interval); clearTimeout(foo) // 中止循環