Promise是JavaScript異步操作解決方案。介紹Promise之前,先對異步操作做一個詳細介紹。
JavaScript的異步執行
概述
Javascript語言的執行環境是”單線程”(single thread)。所謂”單線程”,就是指一次只能完成一件任務。如果有多個任務,就必須排隊,前面一個任務完成,再執行后面一個任務。
這種模式的好處是實現起來比較簡單,執行環境相對單純;壞處是只要有一個任務耗時很長,后面的任務都必須排隊等着,會拖延整個程序的執行。常見的瀏覽器無響應(假死),往往就是因為某一段Javascript代碼長時間運行(比如死循環),導致整個頁面卡在這個地方,其他任務無法執行。
JavaScript語言本身並不慢,慢的是讀寫外部數據,比如等待Ajax請求返回結果。這個時候,如果對方服務器遲遲沒有響應,或者網絡不通暢,就會導致腳本的長時間停滯。
為了解決這個問題,Javascript語言將任務的執行模式分成兩種:同步(Synchronous)和異步(Asynchronous)。”同步模式”就是傳統做法,后一個任務等待前一個任務結束,然后再執行,程序的執行順序與任務的排列順序是一致的、同步的。這往往用於一些簡單的、快速的、不涉及讀寫的操作。
“異步模式”則完全不同,每一個任務分成兩段,第一段代碼包含對外部數據的請求,第二段代碼被寫成一個回調函數,包含了對外部數據的處理。第一段代碼執行完,不是立刻執行第二段代碼,而是將程序的執行權交給第二個任務。等到外部數據返回了,再由系統通知執行第二段代碼。所以,程序的執行順序與任務的排列順序是不一致的、異步的。
以下總結了”異步模式”編程的幾種方法,理解它們可以讓你寫出結構更合理、性能更出色、維護更方便的JavaScript程序。
回調函數
回調函數是異步編程最基本的方法。
假定有兩個函數f1和f2,后者等待前者的執行結果。
1 f1(); 2 f2();
上面代碼中,f2
必須要等到f1
執行完,才能執行。
如果f1
是一個很耗時的任務,可以考慮改寫f1
,把f2
寫成f1
的回調函數。
1 function f1(callback) { 2 setTimeout(function () { 3 // f1的任務代碼 4 // ... 5 callback(); 6 }, 0); 7 }
執行代碼就變成下面這樣。
1 f1(f2);
采用這種方式,我們把同步操作變成了異步操作,setTimeout(fn, 0)
將fn
放到下一輪事件循環執行。f1
不會堵塞程序運行,相當於先執行程序的主要邏輯,將耗時的操作推遲執行。
回調函數的優點是簡單、容易理解和部署,缺點是不利於代碼的閱讀和維護,各個部分之間高度耦合(Coupling),使得程序結構混亂、流程難以追蹤(尤其是回調函數嵌套的情況),而且每個任務只能指定一個回調函數。
事件監聽
另一種思路是采用事件驅動模式。任務的執行不取決於代碼的順序,而取決於某個事件是否發生。
還是以f1
和f2
為例。首先,為f1綁定一個事件(這里采用的jQuery的寫法)。
1 f1.on('done', f2);
上面這行代碼的意思是,當f1發生done事件,就執行f2。然后,對f1進行改寫:
1 function f1(){ 2 setTimeout(function () { 3 // f1的任務代碼 4 f1.trigger('done'); 5 }, 1000); 6 }
上面代碼中,f1.trigger('done')
表示,執行完成后,立即觸發done
事件,從而開始執行f2
。
這種方法的優點是比較容易理解,可以綁定多個事件,每個事件可以指定多個回調函數,而且可以”去耦合“(Decoupling),有利於實現模塊化。缺點是整個程序都要變成事件驅動型,運行流程會變得很不清晰。
發布/訂閱
“事件”完全可以理解成”信號”,如果存在一個”信號中心”,某個任務執行完成,就向信號中心”發布”(publish)一個信號,其他任務可以向信號中心”訂閱”(subscribe)這個信號,從而知道什么時候自己可以開始執行。這就叫做”發布/訂閱模式“(publish-subscribe pattern),又稱”觀察者模式“(observer pattern)。
這個模式有多種實現,下面采用的是Ben Alman的Tiny Pub/Sub,這是jQuery的一個插件。
首先,f2向”信號中心”jQuery訂閱”done”信號。
1 jQuery.subscribe("done", f2);
然后,f1進行如下改寫:
1 function f1(){ 2 setTimeout(function () { 3 // f1的任務代碼 4 jQuery.publish("done"); 5 }, 1000); 6 }
jQuery.publish(“done”)的意思是,f1執行完成后,向”信號中心”jQuery發布”done”信號,從而引發f2的執行。
f2完成執行后,也可以取消訂閱(unsubscribe)。
1 jQuery.unsubscribe("done", f2);
這種方法的性質與”事件監聽”類似,但是明顯優於后者。因為我們可以通過查看”消息中心”,了解存在多少信號、每個信號有多少訂閱者,從而監控程序的運行。
異步操作的流程控制
如果有多個異步操作,就存在一個流程控制的問題:確定操作執行的順序,以后如何保證遵守這種順序。
1 function async(arg, callback) { 2 console.log('參數為 ' + arg +' , 1秒后返回結果'); 3 setTimeout(function() { callback(arg * 2); }, 1000); 4 }
上面代碼的async函數是一個異步任務,非常耗時,每次執行需要1秒才能完成,然后再調用回調函數。
如果有6個這樣的異步任務,需要全部完成后,才能執行下一步的final函數。
1 function final(value) { 2 console.log('完成: ', value); 3 }
請問應該如何安排操作流程?
1 async(1, function(value){ 2 async(value, function(value){ 3 async(value, function(value){ 4 async(value, function(value){ 5 async(value, function(value){ 6 async(value, final); 7 }); 8 }); 9 }); 10 }); 11 });
上面代碼采用6個回調函數的嵌套,不僅寫起來麻煩,容易出錯,而且難以維護。
串行執行
我們可以編寫一個流程控制函數,讓它來控制異步任務,一個任務完成以后,再執行另一個。這就叫串行執行。
1 var items = [ 1, 2, 3, 4, 5, 6 ]; 2 var results = []; 3 function series(item) { 4 if(item) { 5 async( item, function(result) { 6 results.push(result); 7 return series(items.shift()); 8 }); 9 } else { 10 return final(results); 11 } 12 } 13 series(items.shift());
上面代碼中,函數series就是串行函數,它會依次執行異步任務,所有任務都完成后,才會執行final函數。items數組保存每一個異步任務的參數,results數組保存每一個異步任務的運行結果。
並行執行
流程控制函數也可以是並行執行,即所有異步任務同時執行,等到全部完成以后,才執行final函數。
1 var items = [ 1, 2, 3, 4, 5, 6 ]; 2 var results = []; 3 4 items.forEach(function(item) { 5 async(item, function(result){ 6 results.push(result); 7 if(results.length == items.length) { 8 final(results); 9 } 10 }) 11 });
上面代碼中,forEach方法會同時發起6個異步任務,等到它們全部完成以后,才會執行final函數。
並行執行的好處是效率較高,比起串行執行一次只能執行一個任務,較為節約時間。但是問題在於如果並行的任務較多,很容易耗盡系統資源,拖慢運行速度。因此有了第三種流程控制方式。
並行與串行的結合
所謂並行與串行的結合,就是設置一個門檻,每次最多只能並行執行n個異步任務。這樣就避免了過分占用系統資源。
1 var items = [ 1, 2, 3, 4, 5, 6 ]; 2 var results = []; 3 var running = 0; 4 var limit = 2; 5 6 function launcher() { 7 while(running < limit && items.length > 0) { 8 var item = items.shift(); 9 async(item, function(result) { 10 results.push(result); 11 running--; 12 if(items.length > 0) { 13 launcher(); 14 } else if(running == 0) { 15 final(); 16 } 17 }); 18 running++; 19 } 20 } 21 22 launcher();
上面代碼中,最多只能同時運行兩個異步任務。變量running記錄當前正在運行的任務數,只要低於門檻值,就再啟動一個新的任務,如果等於0,就表示所有任務都執行完了,這時就執行final函數。
Promise對象
簡介
Promise對象是CommonJS工作組提出的一種規范,目的是為異步操作提供統一接口。
那么,什么是Promises?
首先,它是一個對象,也就是說與其他JavaScript對象的用法,沒有什么兩樣;其次,它起到代理作用(proxy),充當異步操作與回調函數之間的中介。它使得異步操作具備同步操作的接口,使得程序具備正常的同步運行的流程,回調函數不必再一層層嵌套。
簡單說,它的思想是,每一個異步任務立刻返回一個Promise對象,由於是立刻返回,所以可以采用同步操作的流程。這個Promises對象有一個then方法,允許指定回調函數,在異步任務完成后調用。
比如,異步操作f1
返回一個Promise對象,它的回調函數f2
寫法如下。
1 (new Promise(f1)).then(f2);
這種寫法對於多層嵌套的回調函數尤其方便。
1 // 傳統寫法 2 step1(function (value1) { 3 step2(value1, function(value2) { 4 step3(value2, function(value3) { 5 step4(value3, function(value4) { 6 // ... 7 }); 8 }); 9 }); 10 }); 11 12 // Promises的寫法 13 (new Promise(step1)) 14 .then(step2) 15 .then(step3) 16 .then(step4);
從上面代碼可以看到,采用Promises接口以后,程序流程變得非常清楚,十分易讀。
注意,為了便於理解,上面代碼的Promise對象的生成格式,做了簡化,真正的語法請參照下文。
總的來說,傳統的回調函數寫法使得代碼混成一團,變得橫向發展而不是向下發展。Promises規范就是為了解決這個問題而提出的,目標是使用正常的程序流程(同步),來處理異步操作。它先返回一個Promise對象,后面的操作以同步的方式,寄存在這個對象上面。等到異步操作有了結果,再執行前期寄放在它上面的其他操作。
Promises原本只是社區提出的一個構想,一些外部函數庫率先實現了這個功能。ECMAScript 6將其寫入語言標准,因此目前JavaScript語言原生支持Promise對象。
Promise接口
前面說過,Promise接口的基本思想是,異步任務返回一個Promise對象。
Promise對象只有三種狀態。
- 異步操作“未完成”(pending)
- 異步操作“已完成”(resolved,又稱fulfilled)
- 異步操作“失敗”(rejected)
這三種的狀態的變化途徑只有兩種。
- 異步操作從“未完成”到“已完成”
- 異步操作從“未完成”到“失敗”。
這種變化只能發生一次,一旦當前狀態變為“已完成”或“失敗”,就意味着不會再有新的狀態變化了。因此,Promise對象的最終結果只有兩種。
- 異步操作成功,Promise對象傳回一個值,狀態變為
resolved
。 - 異步操作失敗,Promise對象拋出一個錯誤,狀態變為
rejected
。
Promise對象使用then
方法添加回調函數。then
方法可以接受兩個回調函數,第一個是異步操作成功時(變為resolved
狀態)時的回調函數,第二個是異步操作失敗(變為rejected
)時的回調函數(可以省略)。一旦狀態改變,就調用相應的回調函數。
1 // po是一個Promise對象 2 po.then( 3 console.log, 4 console.error 5 );
上面代碼中,Promise對象po
使用then
方法綁定兩個回調函數:操作成功時的回調函數console.log
,操作失敗時的回調函數console.error
(可以省略)。這兩個函數都接受異步操作傳回的值作為參數。
then
方法可以鏈式使用。
1 po 2 .then(step1) 3 .then(step2) 4 .then(step3) 5 .then( 6 console.log, 7 console.error 8 );
上面代碼中,po
的狀態一旦變為resolved
,就依次調用后面每一個then
指定的回調函數,每一步都必須等到前一步完成,才會執行。最后一個then
方法的回調函數console.log
和console.error
,用法上有一點重要的區別。console.log
只顯示回調函數step3
的返回值,而console.error
可以顯示step1
、step2
、step3
之中任意一個發生的錯誤。也就是說,假定step1
操作失敗,拋出一個錯誤,這時step2
和step3
都不會再執行了(因為它們是操作成功的回調函數,而不是操作失敗的回調函數)。Promises對象開始尋找,接下來第一個操作失敗時的回調函數,在上面代碼中是console.error
。這就是說,Promises對象的錯誤有傳遞性。
1 try { 2 var v1 = step1(po); 3 var v2 = step2(v1); 4 var v3 = step3(v2); 5 console.log(v3); 6 } catch (error) { 7 console.error(error); 8 }
Promise對象的生成
ES6提供了原生的Promise構造函數,用來生成Promise實例。
下面代碼創造了一個Promise實例。
從同步的角度看,上面的代碼大致等同於下面的形式。
1 var promise = new Promise(function(resolve, reject) { 2 // 異步操作的代碼 3 4 if (/* 異步操作成功 */){ 5 resolve(value); 6 } else { 7 reject(error); 8 } 9 });
Promise構造函數接受一個函數作為參數,該函數的兩個參數分別是resolve
和reject
。它們是兩個函數,由JavaScript引擎提供,不用自己部署。
resolve
函數的作用是,將Promise對象的狀態從“未完成”變為“成功”(即從Pending
變為Resolved
),在異步操作成功時調用,並將異步操作的結果,作為參數傳遞出去;reject
函數的作用是,將Promise對象的狀態從“未完成”變為“失敗”(即從Pending
變為Rejected
),在異步操作失敗時調用,並將異步操作報出的錯誤,作為參數傳遞出去。
Promise實例生成以后,可以用then
方法分別指定Resolved
狀態和Reject
狀態的回調函數。
1 po.then(function(value) { 2 // success 3 }, function(value) { 4 // failure 5 });
用法辨析
Promise的用法,簡單說就是一句話:使用then
方法添加回調函數。但是,不同的寫法有一些細微的差別,請看下面四種寫法,它們的差別在哪里?
1 // 寫法一 2 doSomething().then(function () { 3 return doSomethingElse(); 4 }); 5 6 // 寫法二 7 doSomething().then(function () { 8 doSomethingElse(); 9 }); 10 11 // 寫法三 12 doSomething().then(doSomethingElse()); 13 14 // 寫法四 15 doSomething().then(doSomethingElse);
為了便於講解,下面這四種寫法都再用then
方法接一個回調函數finalHandler
。寫法一的finalHandler
回調函數的參數,是doSomethingElse
函數的運行結果。
1 doSomething().then(function () { 2 return doSomethingElse(); 3 }).then(finalHandler);
寫法二的finalHandler
回調函數的參數是undefined
。
1 doSomething().then(function () { 2 doSomethingElse(); 3 return; 4 }).then(finalHandler);
寫法三的finalHandler
回調函數的參數,是doSomethingElse
函數返回的回調函數的運行結果。
1 doSomething().then(doSomethingElse()) 2 .then(finalHandler);
寫法四與寫法一只有一個差別,那就是doSomethingElse
會接收到doSomething()
返回的結果。
1 doSomething().then(doSomethingElse) 2 .then(finalHandler);
Promise的應用
加載圖片
我們可以把圖片的加載寫成一個Promise
對象。
1 var preloadImage = function (path) { 2 return new Promise(function (resolve, reject) { 3 var image = new Image(); 4 image.onload = resolve; 5 image.onerror = reject; 6 image.src = path; 7 }); 8 };
Ajax操作
Ajax操作是典型的異步操作,傳統上往往寫成下面這樣。
1 function search(term, onload, onerror) { 2 var xhr, results, url; 3 url = 'http://example.com/search?q=' + term; 4 5 xhr = new XMLHttpRequest(); 6 xhr.open('GET', url, true); 7 8 xhr.onload = function (e) { 9 if (this.status === 200) { 10 results = JSON.parse(this.responseText); 11 onload(results); 12 } 13 }; 14 xhr.onerror = function (e) { 15 onerror(e); 16 }; 17 18 xhr.send(); 19 } 20 21 search("Hello World", console.log, console.error);
如果使用Promise對象,就可以寫成下面這樣。
1 function search(term) { 2 var url = 'http://example.com/search?q=' + term; 3 var xhr = new XMLHttpRequest(); 4 var result; 5 6 var p = new Promise(function (resolve, reject) { 7 xhr.open('GET', url, true); 8 xhr.onload = function (e) { 9 if (this.status === 200) { 10 result = JSON.parse(this.responseText); 11 resolve(result); 12 } 13 }; 14 xhr.onerror = function (e) { 15 reject(e); 16 }; 17 xhr.send(); 18 }); 19 20 return p; 21 } 22 23 search("Hello World").then(console.log, console.error);
加載圖片的例子,也可以用Ajax操作完成。
1 function imgLoad(url) { 2 return new Promise(function(resolve, reject) { 3 var request = new XMLHttpRequest(); 4 request.open('GET', url); 5 request.responseType = 'blob'; 6 request.onload = function() { 7 if (request.status === 200) { 8 resolve(request.response); 9 } else { 10 reject(new Error('圖片加載失敗:' + request.statusText)); 11 } 12 }; 13 request.onerror = function() { 14 reject(new Error('發生網絡錯誤')); 15 }; 16 request.send(); 17 }); 18 }
注意點
1)一般來說,不要在then方法里面定義 Reject 狀態的回調函數(即then的第二個參數),總是使用catch方法。
// bad promise .then(function(data) { // success }, function(err) { // error }); // good promise .then(function(data) { //cb // success }) .catch(function(err) { // error });
2)Promise 對象后面要跟catch方法
一般總是建議,Promise 對象后面要跟catch方法,這樣可以處理 Promise 內部發生的錯誤。catch方法返回的還是一個 Promise 對象,因此后面還可以接着調用then方法。
代碼運行完catch方法指定的回調函數,會接着運行后面那個then方法指定的回調函數。如果沒有報錯,則會跳過catch方法。此時,要是最后的then方法里面報錯,就與前面的catch無關了。
const someAsyncThing = function() { return new Promise(function(resolve, reject) { // 下面一行會報錯,因為x沒有聲明 resolve(x + 2); }); }; someAsyncThing() .catch(function(error) { console.log('oh no', error); }) .then(function() { console.log('carry on'); }); // oh no [ReferenceError: x is not defined] // carry on
代碼中,第二個catch方法用來捕獲前一個catch方法拋出的錯誤。
someAsyncThing().then(function() { return someOtherAsyncThing(); }).catch(function(error) { console.log('oh no', error); // 下面一行會報錯,因為y沒有聲明 y + 2; }).catch(function(error) { console.log('carry on', error); }); // oh no [ReferenceError: x is not defined] // carry on [ReferenceError: y is not defined]
小結
Promise對象的優點在於,讓回調函數變成了規范的鏈式寫法,程序流程可以看得很清楚。它的一整套接口,可以實現許多強大的功能,比如為多個異步操作部署一個回調函數、為多個回調函數中拋出的錯誤統一指定處理方法等等。
而且,它還有一個前面三種方法都沒有的好處:如果一個任務已經完成,再添加回調函數,該回調函數會立即執行(即你可以做同步操作)。所以,你不用擔心是否錯過了某個事件或信號。這種方法的缺點就是,編寫和理解都相對比較難。