JavaScript異步Promise

JavaScript里通常不建議阻塞主程序,尤其是一些代價比較昂貴的操作,如查找數據庫,下載文件等操作,應該用異步API,如Ajax模式。在執行操作的的同時,主程序能繼續處理后序代碼,等操作結束后調用回調函數。

例如文件下載,同步方式的話,萬一網絡情緒不穩定,下載文件耗時很長,頁面就卡住了,所以一定會用異步API:

downloadAsync("http://example.com/file.txt", function(text) {
    console.log(text);
});

但通常異步操作并非只有單一步驟,就以文件下載為例,如果下載3個有關聯的文件,你要如何才能保證依次下載呢?你可能會用回調嵌套:

downloadAsync("a.txt", function(a) {
    downloadAsync("b.txt", function(b) {
        downloadAsync("c.txt", function(c) {
            console.log("Contents: " + a + b + c);
        }, function(error) {
            console.log("Error: " + error);
        });
    }, function(error) {
        console.log("Error: " + error);
    });
}, function(error) {
    console.log("Error: " + error);
});

回調嵌套本身沒有什么錯。它被詬病之處在于,這才3層,基本框架就很難看了。業務邏輯稍微多一點,即便你努力重構出子函數,也難以避免讓主體框架陷入“回調地獄”,代碼會難以閱讀和維護。

這就是Promise誕生的原因,它并不是全新的數據類型,而是一種組織代碼的方式,內部改進了回調函數,對外暴露出then方法。讓開發者避免陷入“回調地獄”,寫出更加清晰的,類似函數鏈的代碼。ES6將Promise納入了標準,本篇就介紹一下Promise。

  • Promise對象
  • then方法
  • catch方法
  • 靜態方法(resolve,reject,all,race)
  • 例子

Promise對象

Promise既然是一種代碼組織方式,它就需要為用戶提供構造函數來創建一個Promise對象。構造函數原型:new Promise(function(resolve, reject) { … } );。參照MDN

構造函數用一個函數作為參數,該函數有兩個參數,兩個參數均是回調函數,由JS引擎提供,你不用自己部署了。第一個參數resolve,當異步操作成功時會調用,它有一個參數用于傳遞異步操作成功的結果。第二個參數reject,當異步操作失敗時會調用,它有一個參數用于傳遞異步操作失敗的信息。例如:

var myPromise = new Promise(function(resolve, reject) {
    ...  //異步操作
    if( success ) {
        resolve(value);
    } else {
        reject(error);
    }
});

上面生成了一個Promise對象,它代表著一個異步操作。有3種狀態Pending,Resolved(MDN里又稱Fulfilled),Rejected??疵志椭婪謩e表示異步操作中,操作成功,操作失敗。

一旦調用構造函數開始生成Promise對象(如上面myPromise),就會立即執行異步操作。異步操作成功的話,myPromise狀態會從Pending切換成Resolved。失敗的話,狀態從Pending切換成Rejected。狀態改變后就固定了,永遠不會再次改變,這就是Promise的語義,表示“承諾”。一旦承諾,海枯石爛都不會變。實例對象生成后,就能調用then方法了。

then方法

Promise.prototype.then()方法顯然就是Promise的精華。函數聲明:p.then(resolve, reject);。參照MDN

注意它不是靜態方法,需要經由Promise實例對象來調用。

then方法有兩個參數,第一個參數是Promise實例對象為Resolved狀態時的回調函數,它的參數就是上面Promise構造函數里resolve傳遞過來的異步操作成功的結果。

第二個參數可選,是Promise實例對象為Rejected狀態時的回調函數,它的參數就是上面Promise構造函數里reject傳遞過來的異步操作失敗的信息。

例如:

var myPromise = new Promise(function(resolve, reject) {
    console.log("執行異步操作");
    resolve("成功");
    //reject ("失敗啦");
});
console.log("后續操作");

myPromise.then(function(value) {
    console.log(value);
}, function(error) {
     console.log(error);
});
//執行異步操作
//后續操作
//成功

上面結果可以看出,一旦調用構造函數開始生成Promise對象,就會立即執行異步操作,打印出第一行log。由于是異步,會繼續執行生成Promise對象外的代碼,打印出第二行log。異步操作成功后,打印出then里resolve回調函數里的log。

由于通常異常會用下面介紹的catch方法捕捉,因此then方法的第二個參數通常會省略:

myPromise.then(function(value) {
    ... //成功
}).catch(function(error) {
    ... //失敗
});

then方法最強大之處在于,它內部可以使用return或throw來實現鏈式調用。使用return或throw后的返回值是一個新的Promise實例對象(注意,不是原來那個Promise實例對象):

var myPromise = new Promise(function(resolve, reject) {
    resolve(1);
});

myPromise.then(function(value) {
    console.log("第" + value + "一次異步操作成功");  //第1次異步操作成功
    return value+1;
}).then(function(value) {
    console.log("第" + value + "一次異步操作成功");  //第2次異步操作成功
});

myPromise.then(function(value) {
   console.log("第" + value + "一次異步操作成功");   //第1次異步操作成功
});

上面代碼中,myPromise對象第一次調用then時,value為1,打印出log后return出一個匿名Promise對象。

你可能會疑惑,看代碼return value+1;(相當于return 2;)只是返回了一個數字,并沒有返回Promise對象呀。其實它會隱式調用下面介紹的Promise.resolve靜態方法將轉為一個Promise對象。具體怎么轉的,請參照下面介紹的Promise.resolve靜態方法。

如果then方法內部不是return,而是throw,會隱式調用下面介紹的Promise.reject靜態方法返回一個Rejected狀態的Promise對象。

由于then里用return或throw返回的是另一個Promise對象,這就實現了鏈式調用。

代碼中返回得到的匿名Promise對象,由于狀態是Resolved,立即調用鏈式中第二個then方法,value為2。(如果匿名Pormise對象里有異步API,那仍舊會等操作成功或失敗后,再觸發調用鏈中的then方法)

最后一次then方法由myPromise對象調用,因為Promise對象的狀態改變后就固定了,永遠不會再次改變,所以value值仍舊為1。

一個常見的錯誤是,忘記寫return或throw,但卻對then方法使用鏈式調用。如上例中將return語句刪掉:

myPromise.then(function(value) {
    console.log("第" + value + "一次異步操作成功");  //第1次異步操作成功
}).then(function(value) {
    console.log("第" + value + "一次異步操作成功");  //第undefined次異步操作成功
});

結果發現鏈式調用后,下一個then方法得到的是undefined。因為如果不顯式寫return語句的話,JS里的函數會自動 return undefined。這樣就相當于調用下面介紹的Promise.resolve(undefined)。雖然瀏覽器覺得這段代碼合法,不會報錯,但通常來說這不是你期待的結果。因此推薦:

確保處于調用鏈中間的then方法內部永遠顯式的調用return或者throw。

catch方法

Promise.prototype.catch()同樣是實例方法,需要經由Promise實例對象來調用,用于Promise實例對象狀態為Rejected的后續處理,即異常處理。函數聲明:p.catch(reject);。參照MDN

catch方法本質上等價于then(null, reject),因此參數reject在上面介紹過了,是一個回調函數,它的參數就是Promise對象狀態變為Rejected后,傳遞來的錯誤信息。

例如:

var myPromise = new Promise(function(resolve, reject) {
    throw "異步操作失敗";
    resolve("成功");
});

myPromise.then(function(value) {
    console.log(value);
}).catch(function(e) {
    console.log(e);
});
//異步操作失敗

異步操作時throw了異常,導致myPromise狀態變成Rejected。由于then里未定義第二個可選的reject回調函數,所以跳過then方法,進入catch,打印出log。

有個細節要注意,上面異步操作里throw和resolve語句別寫反了,寫反了是不會捕捉異常的:

var myPromise = new Promise(function(resolve, reject) {
    resolve("成功");  //throw和resovle的順序寫反了
    throw "異步操作失敗";
});

myPromise.then(function(value) {
    console.log(value);
}).catch(function(e) {
    console.log(e);
});
//成功

上面代碼未能捕獲異常,并不是throw語句未被執行,throw語句確實被執行了。但由于已經將狀態改為Resolved了,Promise對象的狀態一旦改變將永不再變。因此不會進入catch語句里。即throw異常的話,要保證Promise對象的狀態為Rejected,否則即使throw了異常,也沒有回調函數能捕捉該異常。

那如果catch之前的then里也定義了第二個可選的reject回調函數參數呢?究竟是then的reject捕捉還是catch捕捉?其實明白了catch是then(null, reject)的別名,就能推導出,應該被then的reject捕捉:

myPromise.then(function(value) {
    console.log(value);
},function(e) {
    console.log(e + ",由then的reject回調函數捕捉");
}).catch(function(e) {
    console.log(e);
});
//異步操作失敗,由then的reject回調函數捕捉

上面代碼等價于:

myPromise.then(function(value) {
    console.log(value);
},function(e) {
    console.log(e + ",由then的reject回調函數捕捉");
}).then(null, function(e) {
    console.log(e);
});
//異步操作失敗,由then的reject回調函數捕捉

從結果可以看出,catch等價于then(null, reject),相當于調用鏈上全是then,自然是被第一個定義了reject回調的then捕捉。

當然使用catch看起來代碼更清晰,所以通常會省略then方法的第二個參數reject回調,用catch來捕捉異常。

then的返回值是一個全新的Promise對象,catch也不例外,同樣適用于鏈式調用。雖然通常catch都放在鏈式調用的最后,但沒人強制規定必須這么做,catch后面完全可以繼續接then。同樣的,如果catch未放在調用鏈最后,而是在調用鏈中間的話,別忘了顯示地寫上return或throw語句。

另外Promise對象的異常同樣具有冒泡特性,會一直向后傳遞,直到被捕捉到為止。因此then方法中拋出的異常,也能被catch捕捉。因為調用鏈里then拋出異常相當于將匿名Promise對象的狀態改成Rejected,這樣異常在調用鏈上傳遞,直到異常被捕捉到:

var myPromise = new Promise(function(resolve, reject) {
    resolve("成功");
});

myPromise.then(function(value) {
    console.log(value);
    throw "then的resolve里拋出異常";
}).catch(function(e) {
    console.log(e);
});
//成功
//then的resolve里拋出異常

上面代碼中,異常操作成功,打印出log。但在then的resolve回調函數里又throw出了異常,該異常會被then返回的匿名Promise對象繼續傳遞給catch。如果catch之前有多個then,無論哪里拋出異常,最終都會被catch捕捉到。

那如果異常未被捕捉到會怎么樣呢?例如myPromise.then(…).catch(…).then(…);。最后的then里拋出異常,或者catch里拋出異常(沒錯,catch等價于then(null, reject),自然在catch方法之中,也能再拋出異常),會怎么樣呢?

這就是Promise異常的冒泡和傳統的try/catch代碼塊的異常冒泡的區別:如果Promise異常最終未被捕捉到,不會繼續傳遞到外層代碼,即不會有任何反應:

var myPromise = new Promise(function(resolve, reject) {
    resolve(x);     //error,x未定義
});

myPromise.then(function(e) {
    console.log('成功');}
);

上面代碼沒有catch語句,因此異常未被捕捉到,外層代碼也沒有任何反應,仿佛什么都沒發生(注意,試下來Chrome會拋出x未定義的錯誤)。忘記在最后加上catch,會導致珍貴的異常信息丟失,這對調試來說是異常痛苦的經歷。要記住,通常程序員對自己寫的代碼會很自信,覺得不會拋出一個 error,但現實不同環境下,相同代碼的執行結果會大相徑庭。所以為了將來調試方便,建議:

在promise調用鏈最后添加catch。

myPromise().then(function () {
    …
}).then(function () {
    …
}).catch(console.log.bind(console));

靜態方法(resolve,reject,all,race)

這里介紹的4個都是靜態方法,非實例方法,用Promise對象是無法調用的。

Promise.resolve將對象轉為Promise對象。函數聲明:Promise.resolve(value);。參照MDN

參數是一個對象,轉換成Promise對象返回出去。你可以用該方法將jQuery的Deferred對象轉換成一個Promise對象:

var myPromise = Promise.resolve($.ajax('/xx.json'));

參數分4種情況:

1.如果參數對象本身就是Promise對象,那就將該對象原封不動返回。

2.如果參數對象本身是thenable對象(即對象有then方法),轉為Promise對象并返回后,立即執行thenable對象的then方法。例如:

var myPromise = Promise.resolve({ 
    then: function(resolve) { 
      resolve("成功"); 
    }
});
console.log(myPromise instanceof Promise);

myPromise.then(function(v) {
    console.log(v);
});
//true
//成功

上面代碼中,Promise.resolve的參數里有then方法,因此myPromise對象生成后,立即執行then方法,根據異步操作的結果,調用then里resolve/reject回調函數

3.如果參數對象本身不是thenable對象(即對象沒有then方法),例如一個數字數組等,那會返回一個狀態固定為Resolved的全新的Promise對象:

var myPromise = Promise.resolve([1,2,3]);
myPromise.then(function(v) {
    console.log(v);  
});
//[1,2,3]

由于數組不是具有then方法的對象,返回Promise實例的狀態固定為Resolved,觸發調用then方法。原先的參數對象,會被傳給then方法的resolve回調函數做為參數。

為什么呢?因為Promise.resolve([1,2,3]);等價于:

new Promise(function(resolve, reject) {
    resolve([1,2,3]);
});

4.如果沒有參數,那會返回一個狀態固定為Resolved的全新的空的Promise對象。雖然瀏覽器不會報錯,但也沒什么卵用。

Promise.reject將對象轉換成一個狀態為Rejected的全新的Promise對象。其他同Promise.resolve,不贅述。函數聲明:Promise.reject(error);。參照MDN

該方法常用于調試,顯示棧信息:

Promise.reject(new Error("fail")).then(function(error) {
    // not called
}, function(error) {
    console.log(error);   // Stacktrace
});

Promise.all方法用于將多個Promise實例對象,包裝成一個新的Promise實例對象。函數聲明:Promise.all(iterable);。參照MDN

參數是一個Promise對象數組。例如:

var p = Promise.all([p1, p2, p3]);

p1,p2,p3都是Promise對象,如果不是,就會先調用Promise.resolve方法,將參數轉為Promise對象。(Promise.all方法的參數可以不是數組,但必須具有Iterator接口)

返回值是一個全新的Promise對象。它的狀態由參數的狀態決定:

當p1,p2,p3的狀態都變成Resolved,p的狀態才會變成Resolved。然后p1,p2,p3的返回值組成一個數組,傳遞給p的回調函數。

只要p1,p2,p3中有一個變成Rejected,p的狀態就變成Rejected,此時第一個Rejected的實例的返回值,會傳遞給p的回調函數。

var p1 = Promise.resolve(3);
var p2 = 1337;
var p3 = new Promise((resolve, reject) => {
    setTimeout(resolve, 1000, "foo");
}); 
Promise.all([p1, p2, p3]).then(values => { 
    console.log(values);    //1秒后顯示[3, 1337, "foo"] 
});

all方法用于哪里呢?一個非常常見場景是:程序員想用forEach,for,while等循環來處理他們的異步結果。例如刪除所有臨時文件:

db.allDocs({include_docs: true}).then(function (result) {
    result.rows.forEach(function (row) {        //用forEach刪除所有臨時文件
        db.remove(row.doc);
    });
}).then(function () {
    //我認為執行到這里時,所有臨時文件都已經被刪了。但是我錯了…
});

上面代碼,第一個then的回調里,期望用循環刪除所有臨時文件后,再進入第二個then。但事實上,由于第一個then里沒有return語句(原因是刪除文件是個異步動作,如果寫了return語句,刪文件執行之前就會return),所以返回的是undefined。因此第二個then并不是在所有文件均被刪除后才執行的,實際上,第二個then的執行不會有任何延遲,會被立即執行。

如果第二個then里并不需要對臨時文件進行任何操作,那這個bug可能會隱藏的很深。但如果在第二個then里刷新UI頁面,那時不時UI頁面上會出現一些還來不及刪掉的文件,這取決于刪文件的速度。程序員都知道,最討厭的不是出bug,而是出了看運氣才能再現的bug。

所以應該用Promise.all來寫這種需要循環處理異步的操作,可以理解為異步的循環。

db.allDocs({include_docs: true}).then(function (result) {
    return Promise.all(result.rows.map(function (row) {
        return db.remove(row.doc);
    }));
}).then(function (arrayObject) {
    //我承諾執行到這里時,所有臨時文件都已經被刪掉了。
})

Promise.race方法和all方法類似,函數聲明:Promise.race(iterable);,參照MDN

var p = Promise.race([p1,p2,p3]);

參數p1,p2,p3都是Promise對象,如果不是,就會先調用Promise.resolve方法,將參數轉為Promise對象,這點和all相同,不贅述。

和Promise.all的區別是,race表示競爭。只要任意一個參數對象的狀態發生改變,就會立即返回一個相同狀態的Promise對象。

簡單地說:all里全為Resolved才返回Resolved,有一個為Rejected就返回Rejected。race里有一個為Resolved/Rejected就返回Resolved/Rejected

race常用于競爭異步執行的結果場景,例如:指定時間內沒有獲得結果就Rejected:

var p = Promise.race([
    fetch('/resource'),
    new Promise(function (resolve, reject) {
        setTimeout(() => reject(new Error('request timeout')), 5000)
    })
]);
p.then(response => console.log(response))
  .catch(error => console.log(error));

上面代碼中,如果5秒之內無法fetch到數據,p的狀態就會變為Rejected,從而觸發catch方法指定的回調函數。

例子

例如我們要做兩次異步操作(用最簡單的異步操作setTimeout為例),第一次異步操作成功后執行第二次異步操作:

function delay(time, callback){
    setTimeout(function(){
        callback("sleep "+time);
    },time);
}   

delay(1000,function(msg){
    console.log(msg);
    delay(2000,function(msg){
        console.log(msg);
    });
});
//1秒后打印出:sleep 1000
//再過2秒打印出:sleep 2000

上面用回調嵌套很容易實現,但也發現才兩層(還沒加上異常處理),代碼就比較難看了。改成Promise試試:

function delay(time, callback){
    setTimeout(function(){
        callback("sleep "+time);
    },time);
} 

var p = new Promise(function (resolve, reject) {
    delay(1000, resolve);
});

p.then(function(value) {
    console.log(value);
    return new Promise(function (resolve, reject) {
        delay(2000, resolve);
    });
}).then(function(value) {
    console.log(value);
}).catch(console.log.bind(console));

因為才兩個異步操作,所以代碼行數上看,優勢不明顯。優勢在于Promise讓代碼的邏輯流程變得更清晰。先執行第一次異步操作,成功后進入then,打印出結果,并執行第二次異步操作,成功后進入then,打印出結果。

總結

ES6 Promise的實現嚴格遵循了Promise/A+規范,用Promise可以寫出更可讀的異步編程代碼,避免了回調地獄。需要注意的是,由于歷史原因,有的庫,例如jQuery的Deferred就不是Promise/ A+的規范,它是個工廠類,返回的是內部構建的deferred對象。本篇篇幅不夠去介紹Deferred和Promise的區別,但ES6的Promise完全可以取代Deferred。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 本文適用的讀者 本文寫給有一定Promise使用經驗的人,如果你還沒有使用過Promise,這篇文章可能不適合你,...
    HZ充電大喵閱讀 7,327評論 6 19
  • Promise的含義: ??Promise是異步編程的一種解決方案,比傳統的解決方案——回調函數和事件——更合理和...
    呼呼哥閱讀 2,189評論 0 16
  • 00、前言Promise 是異步編程的一種解決方案,比傳統的解決方案——回調函數和事件——更合理和更強大。它由社區...
    夜幕小草閱讀 2,139評論 0 12
  • Promiese 簡單說就是一個容器,里面保存著某個未來才會結束的事件(通常是一個異步操作)的結果,語法上說,Pr...
    雨飛飛雨閱讀 3,373評論 0 19
  • 貴義職教開會內容宿舍:班級統一 學校培養:基本素質 公司專業知識 學校7S標準化:整理、整頓、清潔、清掃、...
    付佳婕閱讀 477評論 0 0