咱們將經過回顧第一篇文章中單線程編程的缺點,而後在討論如何克服它們來構建使人驚歎的JavaScript UI。在文章結尾處,咱們將分享5個關於如何使用async / await編寫更簡潔的代碼的技巧。javascript
第一篇文章中,咱們分析了若是在Call Stack中調用耗時長的函數,會產生不少問題。java
想象一下,一個複雜圖像轉換算法在瀏覽器中運行。jquery
當Call Stack有函數須要執行的時候,瀏覽器是沒法執行其餘任何操做的 - 沒錯它被阻塞了。這意味着瀏覽器沒法渲染頁面,也不能運行任何其餘代碼,它只是卡住了。問題來了 - 您的應用用戶界面再也不高效和使人滿意。ajax
在某些狀況下,這可能不是相當重要的問題。可是,它可能引發一個更大的問題。一旦您的瀏覽器開始處理Call Stack中的太多任務,它可能會中止響應很長時間。不少瀏覽器會彈出錯誤處理窗口,詢問他們是否應該終止該頁面,這很醜陋,它徹底毀了你的用戶體驗:
算法
您可能會把全部JavaScript代碼寫入一個.js文件,可是你的代碼幾乎確定由幾個塊組成,其中只有一個將當即執行,其他的將在稍後執行。最多見的塊單位是函數。編程
大多數新的JavaScript的開發者彷佛都有這樣的理解,即之後不必定會嚴格地當即發生。換句話說,根據定義,如今沒法完成的任務將異步完成,這意味着您不會出現上述阻止行爲,由於您可能已經潛意識地預期或指望。segmentfault
咱們來看看下面的例子:api
// ajax(..) 是由其它工具庫提供的函數 var response = ajax('https://example.com/api'); console.log(response); // `response` 不會有結果
您可能知道標準的Ajax請求是不會同步完成的,這意味着在代碼執行時,ajax(..)函數尚未任何值返回以分配給response變量。數組
一個簡單實現「等待」異步函數返回結果的方法就是callback的函數:promise
ajax('https://example.com/api', function(response) { console.log(response); // `response` 有值了 });
請注意:您實際上能夠建立同步的Ajax請求。可是永遠不要這樣作,若是您發出同步Ajax請求,您的JavaScript應用的用戶界面將被阻塞 - 用戶將沒法點擊,或輸入數據,導航或滾動。這將阻止任何用戶交互。沒錯這是一個可怕的作法。
同步ajax請求代碼以下,但請不要這樣作:
// 假設你是用jquery庫 jQuery.ajax({ url: 'https://api.example.com/endpoint', success: function(response) { // 你的回調函數 }, async: false // 壞主意 });
Ajax請求只是其中一個例子。你可讓任何代碼塊異步執行。
這個能夠經過setTimeout(回調,毫秒)函數來完成。setTimeout函數的做用是設置一個事件(超時)過一段時間再執行。 讓咱們來看看:
function first() { console.log('first'); } function second() { console.log('second'); } function third() { console.log('third'); } first(); setTimeout(second, 1000); // Invoke `second` after 1000ms third();
輸出以下:
first third second
儘管容許異步JavaScript代碼(如咱們剛纔討論的setTimeout),但直到ES6,JavaScript自己實際上歷來沒有內置任何異步的直接概念,但咱們將從一個有點奇怪的說法開始。 JavaScript引擎歷來沒有作過比在任何特定時刻執行單個程序塊更多的事情。
有關JavaScript引擎如何工做的(特別是Google的V8),請查看本系列第三篇文章。
那麼,誰告訴JS引擎來執行你的程序塊?實際上,JS引擎並非孤立運行的 - 它運行在一個託管環境中,對於大多數開發人員來講,它是Web瀏覽器或Node.js。事實上,如今,JavaScript被嵌入到各類設備中,從機器人到燈泡。每一個設備都表明JS Engine的不一樣類型的託管環境。
全部環境中的共同點是一種稱爲事件循環的內置機制,它隨着時間的推移處理程序中多個代碼塊的執行,每次調用JS引擎。
這意味着JS引擎只是JS代碼的按需執行環境。它是調度事件(JS代碼執行)的周圍環境。
例如,當您的JavaScript程序發出Ajax請求,想要從服務器獲取一些數據時,您能夠在函數中設置「響應」代碼(「回調」),而且JS引擎會告訴主機環境:
「嘿,我如今暫停執行,但每當你完成這個網絡請求,而且你有一些數據,請執行這個函數。」
而後設置瀏覽器來偵聽來自網絡的響應,當它返回給您時,它將經過將回調函數插入到事件循環中來安排執行回調函數。
咱們來看下面的圖表:
您能夠在本系列第一篇文章中閱讀關於內存堆和調用堆棧的更多信息。
這些Web API是什麼?從本質上講,它們是你沒法訪問的線程,你能夠對它們進行調用。它們是瀏覽器併發功能的一部分。若是您是Node.js開發人員,那麼這些是C++ API。
那麼到底是什麼事件循環呢?
事件循環只有一個簡單的工做 - 監視Call Stack(調用堆棧)和Callback Queue(回調隊列)。若是調用堆棧爲空,它將從回調隊列中取出第一個事件並將其推送到調用堆棧,該調用堆棧能夠有效地運行它。
這種迭代在事件循環中稱爲tick。每一個事件只是一個函數回調。
console.log('Hi'); setTimeout(function cb1() { console.log('cb1'); }, 5000); console.log('Bye');
讓咱們「執行」這段代碼,看看會發生什麼:
扼要重述:
有趣的是,ES6指定了事件循環應該如何工做,這意味着它在JS引擎的職責範圍內,而再也不只是屬於一個託管環境。這種變化的一個主要緣由是在ES6中引入了Promises,由於後者須要對事件循環隊列上的調度操做進行直接,細粒度的控制(咱們稍後會更詳細地討論它們)。
請注意,setTimeout(...)不會自動將您的回調函數放到事件循環隊列中。它設置了一個計時器,當計時器到期時,環境將您的回調函數放入事件循環中,以便未來的某個tick事件會將其選中並執行它。查看此代碼:
setTimeout(myCallback, 1000);
這並不意味着myCallback將在1000ms以後立刻執行,而是在1000ms以後,myCallback將被添加到隊列中。可是隊列中可能還有其餘事件先前已添加 - 您的回調將不得不等待。
有不少關於開始使用JavaScript中的異步代碼的文章和教程,其中提到了setTimeout(callback,0)。 那麼,如今你知道Event Loop的做用了,以及setTimeout如何工做:使用0做爲第二個參數調用setTimeout只是推遲迴調函數執行,直到調用堆棧清空才執行。
看看下面的代碼:
console.log('Hi'); setTimeout(function() { console.log('callback'); }, 0); console.log('Bye');
雖然等待時間設置爲0 ms,但瀏覽器控制檯中的結果以下所示:
Hi Bye callback
ES6中引入了一個名爲「Job隊列」的新概念。它是Event Loop隊列頂部的一個層。在處理Promises的異步行爲時,您最有可能接觸到它(咱們也將討論它們)。
如今咱們將簡單介紹這個概念,以便在咱們稍後討論Promise的異步行爲時,您將瞭解如何安排和處理這些操做。
想象一下:Job隊列是一個鏈接到事件循環隊列中每一個tick的末尾的隊列。在事件循環的tick期間可能發生的某些異步操做不會致使將全新的事件添加到事件循環隊列中,而是會將一個項(又名Job)添加到當前tick的Job隊列的末尾。
這意味着您能夠添加其餘功能以便稍後執行,您能夠放心,它將在執行任何其餘操做以前當即執行。
Job還可使更多做業添加到同一隊列的末尾。從理論上講,做業「循環」(一個不停地添加其餘做業等的做業)可能會無限地旋轉,從而致使須要進入下一個事件循環節點所需的必要資源的程序不足。從概念上講,這與在代碼中僅表示長時間運行或無限循環(如while(true)..)相似。
做業有點像setTimeout(回調,0)「破解」,但實現的方式是它們引入了一個更加明確和有保證的排序:稍後,但儘快。
如您所知,回調是迄今爲止在JavaScript程序中表達和管理異步的最多見方式。事實上,回調是JavaScript語言中最基本的異步模式。無數的JS程序,甚至是很是複雜和複雜的程序,都是在沒有其餘異步基礎的狀況下編寫的,而不是回調。
除了回調不具備缺點。許多開發人員正試圖找到更好的異步模式。然而,若是你不瞭解底層實際狀況,那麼有效地使用任何抽象概念是不可能的。
在下一章中,咱們將深刻探索這些抽象概念,以說明爲何更復雜的異步模式是必要的甚至是推薦的(將在後續的帖子中討論)。
看下面的代碼:
listen('click', function (e){ setTimeout(function(){ ajax('https://api.example.com/endpoint', function (text){ if (text == "hello") { doSomething(); } else if (text == "world") { doSomethingElse(); } }); }, 500); });
咱們有一個嵌套在一塊兒的三個函數,每一個函數表明一個異步過程。
這種代碼一般被稱爲「回調地獄」。但「回撥地獄」實際上與嵌套/縮進幾乎沒有任何關係。這是一個比這更深的問題。
首先,咱們正在等待「click」事件,而後等待計時器開始工做,而後等待Ajax響應返回,此時它可能會再次重複。
乍一看,這段代碼看起來能夠將其異步映射爲連續的步驟:
listen('click', function (e) { // .. });
以後:
setTimeout(function(){ // .. }, 500);
最後:
if (text == "hello") { doSomething(); } else if (text == "world") { doSomethingElse(); }
所以,表達異步代碼的這種順序方式彷佛更加天然,不是嗎? 必定有這樣的方式吧?
看看下面的代碼:
var x = 1; var y = 2; console.log(x + y);
它很是簡單:它將x和y的值相加並打印到控制檯。可是,若是x或y的值須要異步返回,該怎麼辦?比方說,咱們須要從服務器中檢索x和y的值,而後才能在表達式中使用它們。假設咱們有一個函數loadX和loadY,分別從服務器加載x和y的值。而後,想象一下,咱們有一個函數sum,返回x加y的值。
它可能看起來像這樣(至關醜陋):
function sum(getX, getY, callback) { var x, y; getX(function(result) { x = result; if (y !== undefined) { callback(x + y); } }); getY(function(result) { y = result; if (x !== undefined) { callback(x + y); } }); } // 一個同步或異步函數返回x的值 function fetchX() { // .. } // 一個同步或異步函數返回y的值 function fetchY() { // .. } sum(fetchX, fetchY, function(result) { console.log(result); });
這裏有一些很是重要的東西 - 在這個代碼中,咱們將x和y做爲將來值,而且咱們表達了一個操做和(...)(從外部)不關心x或y或者二者是否都不可用 立刻。
固然,這種基於簡單回調的方法還有不少不足之處。這只是爲了解feature values的好處的第一步,而沒必要擔憂它們什麼時候可用。
讓咱們簡要地看一下咱們如何用Promises來表達x + y示例:
function sum(xPromise, yPromise) { // `Promise.all([ .. ])` 接受 promises 數組, // 返回一個新的promise,這個promise會等待全部promise數組完成 return Promise.all([xPromise, yPromise]) // Promise.all被resolved以後, 咱們將返回的X和Y相加 .then(function(values){ // `values` 是以前promises數組中每一個promise解決以後的信息組成的數組 return values[0] + values[1]; } ); } // `fetchX()` and `fetchY()` 返回promise,promise包含各自的值 // 這個值可能可用也可能不可用 sum(fetchX(), fetchY()) // 咱們最終獲得一個promise,它返回了兩個數字的和 // 調用 `then(...)` 獲得最終值 .then(function(sum){ console.log(sum); });
在這個片斷中有兩層Promise。
直接調用fetchX()和fetchY(),並將它們返回的值(promise!)傳遞給sum(...)。這些承諾所表明的基礎價值可能如今已經準備就緒,可是每一個承諾都將其行爲規範化爲不管如何都是相同的。咱們以時間無關的方式推測x和y值。他們是將來的價值觀,期限。
第二層是sum(...)建立的承諾
(經過Promise.all([...]))和返回,咱們經過調用而後等待(...)。總和(...)操做完成後,咱們的總和將來值已準備好,咱們能夠將其打印出來。咱們隱藏了等待sum(...)中x和y將來值的邏輯。
注意:Inside sum(...)中,Promise.all([...])調用建立一個承諾(等待promiseX並promiseY解析)。而後(...)的連接調用建立了另外一個承諾,即返回
值[0] +值[1]行當即解決(與加法的結果)。所以,咱們鏈接sum(...)調用結束時的then(...)調用 - 在片斷結尾處 - 其實是在返回的第二個promise上運行,而不是由Promise建立的第一個promise。所有([...])。另外,雖然咱們並無把時間的尾端鏈接起來(...),可是若是咱們選擇觀察/使用它,它也創造了另外一個承諾。本章後面將詳細解釋這個Promise連接的東西。
有了Promises,那麼(...)調用實際上能夠採用兩個函數,第一個用於履行(如前所示),第二個用於拒絕:
sum(fetchX(), fetchY()) .then( // fullfillment handler function(sum) { console.log( sum ); }, // rejection handler function(err) { console.error( err ); // bummer! } );
若是在獲取x或y時出現問題,或者在添加期間某種方式失敗了,那麼sum(...)返回的promise將被拒絕,而且傳遞給then(...)的第二個回調錯誤處理程序將收到拒絕 來自諾言的價值。
因爲Promises封裝了時間依賴狀態 - 等待基礎價值的實現或拒絕 - 從外部看,Promise自己是時間無關的,所以Promises能夠以可預測的方式組合(組合),而無論時間或結果如何 下。
並且,一旦一個承諾解決了,它就會永遠保持這種狀態 - 它在那個時候成爲一個不變的價值 - 而後能夠根據須要屢次觀察。
確實能夠連接承諾是很是有用的:
function delay(time) { return new Promise(function(resolve, reject){ setTimeout(resolve, time); }); } delay(1000) .then(function(){ console.log("after 1000ms"); return delay(2000); }) .then(function(){ console.log("after another 2000ms"); }) .then(function(){ console.log("step 4 (next Job)"); return delay(5000); }) // ...
呼叫延遲(2000)建立了一個將在2000ms完成的承諾,而後咱們從第一個(...)履行回調中返回,這致使第二個(...)的承諾等待2000ms的承諾。
注意:由於Promise一旦解決就是外部不可變的,如今能夠安全地將該值傳遞給任何一方,並知道它不能被意外或惡意修改。 關於觀察解決諾言的多方,這一點尤爲如此。 一方不可能影響另外一方遵照Promise解決方案的能力。 不變性可能聽起來像是一個學術話題,但它其實是Promise設計的最基本和最重要的方面之一,不該該隨便傳遞。
關於Promises的一個重要細節是確切地知道某個值是不是實際的Promises。 換句話說,這是一種會表現得像一個Promise?
咱們知道Promise是由new Promise(...)語法構造的,您可能認爲Promise的instanceof將是一個有效的檢查。好吧,不是。
主要是由於您能夠從另外一個瀏覽器窗口(例如iframe)接收Promise值,該窗口具備與當前窗口或框架中的承諾不一樣的Promise,而且該檢查沒法識別Promise實例。
此外,庫或框架可能會選擇出售本身的Promises,而不是使用原生ES6的Promise實施來實現。 事實上,你可能會在早期的瀏覽器中使用Promises和Promise來實現Promise。
若是在建立Promise或觀察其解決方案的任什麼時候候發生JavaScript異常錯誤(例如TypeError或ReferenceError),該異常將被捕獲,而且它將強制有問題的Promise被拒絕。
例如:
var p = new Promise(function(resolve, reject){ foo.bar(); // `foo` 沒有被定義, 因此會發出異常或錯誤 resolve(374); // 不會運行到這裏 :( }); p.then( function fulfilled(){ // 不會運行到這裏 :( }, function rejected(err){ // `err` 是一個 `TypeError` 異常對象 // 異常發生在 `foo.bar()` 這一行. } );
可是若是一個Promise被實現時,在observation期間(在一個then(...)註冊的回調中)有一個JS異常錯誤會發生什麼? 即便它不會丟失,你可能會發現它們的處理方式有點使人驚訝。直到你深刻一點:
var p = new Promise( function(resolve,reject){ resolve(374); }); p.then(function fulfilled(message){ foo.bar(); console.log(message); // 沒有運行到這裏 }, function rejected(err){ // 沒有運行到這裏 } );
它看起來像來自foo.bar()的異常真的被吞噬了。不過事實上並不是如此。然而,有些更深層的事情發生了錯誤,但咱們沒有監聽到。p.then(...)調用自己會返回另外一個promise,這就是那個將被TypeError異常拒絕的promise
還有其餘的方法,不少人會說更好。
一個常見的建議是Promise應該使用done(...),它們基本上將Promise鏈標記爲「已完成」。done(...)不會建立並返回Promise,因此回調函數傳遞給done(..)顯然沒有鏈接到向不存在的鏈式承諾報告問題。
它的處理方式與您在未捕獲的錯誤狀況中一般所期待的同樣:done(..)裏面的異常或錯誤,將做爲全局未捕獲錯誤引起(基本上在開發人員控制檯中):
var p = Promise.resolve(374); p.then(function fulfilled(msg){ // 數字是不會有字符串的處理函數 // 因此會拋出異常 console.log(msg.toLowerCase()); }) .done(null, function() { // 若是異常在這裏發生,它會全局拋出 });
JavaScript ES8(ES2017)引入了async/await,這使得使用Promises的工做更容易。咱們將簡要介紹async/await提供的可能性以及如何利用它們來編寫異步代碼。
那麼,讓咱們看看async/await如何工做。
您可使用async關鍵字聲明定義一個異步函數。這樣的函數返回一個AsyncFunction對象。 AsyncFunction對象表示執行該函數中包含的代碼的異步函數。
當一個異步函數被調用時,它返回一個Promise。當異步函數返回一個值時,這不是一個Promise,Promise將會自動建立,而且會使用函數返回的值來解析。當異步函數拋出異常時,Promise將被拋出的值拒絕。
異步函數能夠包含await表達式,暫停執行該函數並等待傳遞的Promise的解析,而後恢復異步函數的執行並返回解析後的值。
您能夠將JavaScript中的Promise等同於Java的Future或C#的Task。
async/await的目的是爲了簡化使用promises。
咱們來看看下面的例子:
// 標準的javascript函數 function getNumber1() { return Promise.resolve('374'); } // 功能和getNumber相同 async function getNumber2() { return 374; }
一樣,拋出異常的函數等價於返回已被reject的promise的函數:
function f1() { return Promise.reject('Some error'); } async function f2() { throw 'Some error'; }
await關鍵字只能用於異步功能,並容許您同步等待Promise。 若是咱們在異步函數以外使用promise,咱們仍然必須使用回調函數:
async function loadData() { // `rp` is a request-promise function. var promise1 = rp('https://api.example.com/endpoint1'); var promise2 = rp('https://api.example.com/endpoint2'); // Currently, both requests are fired, concurrently and // now we'll have to wait for them to finish var response1 = await promise1; var response2 = await promise2; return response1 + ' ' + response2; } // Since, we're not in an `async function` anymore // we have to use `then`. loadData().then(() => console.log('Done'));
還可使用「異步函數表達式」來定義異步函數。 異步函數表達式與異步函數語句很是類似,語法幾乎相同。異步函數表達式和異步函數語句之間的主要區別在於函數名稱,在異步函數表達式中能夠省略這些名稱以建立匿名函數。異步函數表達式能夠用做IIFE(當即調用的函數表達式),只要定義它就當即運行。
它看起來像這樣:
var loadData = async function() { // `rp` is a request-promise function. var promise1 = rp('https://api.example.com/endpoint1'); var promise2 = rp('https://api.example.com/endpoint2'); // Currently, both requests are fired, concurrently and // now we'll have to wait for them to finish var response1 = await promise1; var response2 = await promise2; return response1 + ' ' + response2; }
更重要的是,全部主流瀏覽器都支持async/await:
工做一天結束時,重要的是不要盲目選擇「最新」方法編寫異步代碼。理解異步JavaScript的內部特性相當重要,並深刻了解所選方法的內部原理。與編程中的其餘全部方法同樣,每種方法都有優勢和缺點。
// `rp` is a request-promise function. rp(‘https://api.example.com/endpoint1').then(function(data) { // … });
使用async/await以後:
// `rp` is a request-promise function. var response = await rp(‘https://api.example.com/endpoint1');
async/await使相同的代碼結構來處理同步或異步的錯誤(或異常)稱爲可能,好比熟悉的try/catch語句,下面的例子使用Promises:
function loadData() { try { // 捕獲同步錯誤 getJSON().then(function(response) { var parsed = JSON.parse(response); console.log(parsed); }).catch(function(e) { // 捕獲異步錯誤 console.log(e); }); } catch(e) { console.log(e); } }
使用async/await以後:
async function loadData() { try { var data = JSON.parse(await getJSON()); console.log(data); } catch(e) { console.log(e); } }
function loadData() { return getJSON() .then(function(response) { if (response.needsAnotherRequest) { return makeAnotherRequest(response) .then(function(anotherResponse) { console.log(anotherResponse) return anotherResponse }) } else { console.log(response) return response } }) }
使用async/await以後:
async function loadData() { var response = await getJSON(); if (response.needsAnotherRequest) { var anotherResponse = await makeAnotherRequest(response); console.log(anotherResponse) return anotherResponse } else { console.log(response); return response; } }
使用promise鏈,很難定位錯誤發生的位置:
function loadData() { return callAPromise() .then(callback1) .then(callback2) .then(callback3) .then(() => { throw new Error("boom"); }) } loadData() .catch(function(e) { console.log(err); // Error: boom at callAPromise.then.then.then.then (index.js:8:13) });
使用async/await以後:
async function loadData() { await callAPromise1() await callAPromise2() await callAPromise3() await callAPromise4() await callAPromise5() throw new Error("boom"); } loadData() .catch(function(e) { console.log(err); // output // Error: boom at loadData (index.js:7:9) });
經過異步/等待,您能夠徹底按照正常的同步功能一步一步地調試。