學習Promise非同步程式設計

川南煙雨發表於2020-12-30

JavaScript引擎建立在單執行緒事件迴圈的概念上。單執行緒( Single-threaded )意味著同一時刻只能執行一段程式碼。所以引擎無須留意那些“可能”執行的程式碼。程式碼會被放置在作業佇列( job queue )中,每當一段程式碼準備被執行,它就會被新增到作業佇列。當 JS 引擎結束當前程式碼的執行後,事件迴圈就會執行佇列中的下一個作業.事件迴圈(event loop)是JS引擎的一個內部處理執行緒,能監視程式碼的執行並管理作業佇列。關於事件迴圈可以閱讀這篇文章 ---- 一文梳理JavaScript 事件迴圈(Event Loop)

1. 為什麼要用Promise?

1.1 事件模型

當使用者點選一個按鈕或按下鍵盤上的一個鍵時,一個事件,例如 onclick 就被觸發了。該事件可能會對此互動進行響應,從而將一個新的作業新增到作業佇列的尾部。這就是 JavaScript 關於非同步程式設計的最基本形式。事件處理程式程式碼直到事件發生後才會被執行,此時它會擁有合適的上下文。例如:

let button = document.getElementById("my-btn");
button.onclick = function(event) {
	console.log("Clicked");
};

事件可以很好地工作於簡單的互動,但將多個分離的非同步呼叫串聯在一起卻會很麻煩。此外,還需確保所有的事件處理程式都能在事件第一次觸發之前被繫結完畢。例如,若 button 在onclick被繫結之前就被點選,那就不會有任何事發生。因此雖然在響應使用者互動或類似的低頻功能時,事件很有用,但它在面對更復雜的需求時仍然不夠靈活

1.2 回撥函式

回撥函式模式類似於事件模型,因為非同步程式碼也會在後面的一個時間點才執行。不同之處在於需要呼叫的函
數(即回撥函式)是作為引數傳入的。

eadFile("example.txt", function(err, contents) {
	if (err) {
		throw err;
	}
	console.log(contents);
});
console.log("Hi!");

使用回撥函式模式,readFile() 會立即開始執行,並在開始讀取磁碟時暫停。這意味著console.log("Hi!") 會在 readFile() 被呼叫後立即進行輸出,要早於console.log(contents) 的列印操作。當 readFile() 結束操作後,它會將回撥函式以及相關引數作為一個新的作業新增到作業佇列的尾部。在之前的作業全部結束後,該作業才會執行。回撥函式模式要比事件模型靈活得多,因為使用回撥函式串聯多個呼叫會相對容易。

這種模式運作得相當好,但容易陷入了回撥地獄( callback hell ),這會在巢狀過多回撥函式時發生。當想要實現更復雜的功能時,回撥函式也會存在問題。如讓兩個非同步操作並行執行,並且在它們都結束後提醒你;同時啟動兩個非同步操作,但只採用首個結束的結果;在這些情況下,需要追蹤多個回撥函式並做清理操作, Promise 能大幅度改善這種情況。

2. Promise基礎

Promise 是非同步程式設計的一種解決方案,相比回撥函式和事件,更加強大。它由社群最早提出和實現,ES6 將其寫進了語言標準,統一了用法,原生提供了Promise物件。

Promise 是為非同步操作的結果所準備的佔位符。函式可以返回一個 Promise,而不必訂閱一個事件或向函式傳遞一個回撥引數。

/ readFile 承諾會在將來某個時間點完成
let promise = readFile("example.txt");

每個 Promise 都會經歷一個短暫的生命週期,初始為pending ,這表示非同步操作尚未結束。一個狀態為pending的 Promise 也被認為是未決的( unsettled )。一旦非同步操作結束, Promise就會被認為是已決的(settled),並進入兩種可能狀態之一:

  • fulfilled(已完成): Promise 的非同步操作已成功結束
  • rejected(已拒絕):Promise 的非同步操作未成功結束,可能是一個錯誤,或由其他原因導致

內部的 [[PromiseState]] 屬性會被設定為 "pending" 、 "fulfilled" 或 "rejected" ,以反映 Promise 的狀態。該屬性並未在 Promise 物件上被暴露出來,因此你無法以程式設計方式判斷 Promise 到底處於哪種狀態。

2.1 Promise特質及優點

Promise物件有以下兩個特點。

(1)物件的狀態不受外界影響。只有非同步操作的結果,可以決定當前是哪一種狀態,任何其他操作都無法改變這個狀態。這也是Promise這個名字的由來,它的英語意思就是“承諾”,表示其他手段無法改變。

(2)一旦狀態改變,就不會再變,任何時候都可以得到這個結果。Promise物件的狀態改變,只有兩種可能:從pending變為fulfilled和從pending變為rejected。只要這兩種情況發生,狀態就凝固了,不會再變了,會一直保持這個結果,這時就稱為 resolved(已定型)。如果改變已經發生了,再對Promise物件新增回撥函式,也會立即得到這個結果。這與事件(Event)完全不同,事件的特點是,如果你錯過了它,再去監聽,是得不到結果的。

有了Promise物件,就可以將非同步操作以同步操作的流程表達出來,避免了層層巢狀的回撥函式。此外,Promise物件提供統一的介面,使得控制非同步操作更加容易。

2.2 Promise缺點

  • 無法取消Promise,一旦新建它就會立即執行,無法中途取消。
  • 如果不設定回撥函式,Promise內部丟擲的錯誤,不會反應到外部
  • 當處於pending狀態時,無法得知目前進展到哪一個階段(剛剛開始還是即將完成)

3.建立Promise物件

3.1 建立未決的Promise

ES6 規定,Promise物件是一個建構函式,用來生成Promise例項。Promise 新建後就會立即執行。

Promise建構函式接受一個函式作為引數,該函式的兩個引數分別是resolvereject。它們是兩個函式,由 JavaScript 引擎提供,不用自己部署。

resolve函式的作用是,將Promise物件的狀態從“未完成”變為“成功”(即從 pending 變為 resolved),在非同步操作成功時呼叫,並將非同步操作的結果,作為引數傳遞出去;reject函式的作用是,將Promise物件的狀態從“未完成”變為“失敗”(即從 pending 變為 rejected),在非同步操作失敗時呼叫,並將非同步操作報出的錯誤,作為引數傳遞出去。

function timeout(ms) {
  return new Promise((resolve, reject) => {
    setTimeout(resolve, ms, 'done');
  });
}

timeout(100).then((value) => {
  console.log(value); // done
});

Promise例項生成以後,可以用then方法分別指定resolved狀態和rejected狀態的回撥函式。then方法可以接受兩個回撥函式作為引數。第一個回撥函式是Promise物件的狀態變為resolved時呼叫,第二個回撥函式是Promise物件的狀態變為rejected時呼叫。這兩個函式都是可選的,不一定要提供。它們都接受Promise物件傳出的值作為引數。

3.2 建立已決的Promise

使用Promise.resolve()和Promise.reject()方法能夠建立已決的Promise物件,前提是傳入引數不為pending態的Promise例項,並被Promise.resolve()方法呼叫。

(1) 引數為空

Promise.resolve()方法呼叫時不帶引數,直接返回一個resolved狀態的 Promise 物件。Promise.reject()方法呼叫時不帶引數,直接返回一個rejected狀態的 Promise 物件。

(2) 引數為Promise例項

注意:如果傳遞一個Promise給Promise.resolve(),則不做任何修改、原封不動地返回這個Promise。;傳遞給Promise.reject(),則會在原 Promise 上包裝出一個新的 Promise。示例如下所示:

// 傳入Promise狀態為resolved
let promise1 = Promise.resolve(43);

let promise2 = Promise.resolve(promise1); // Promise { 43 }
console.log(promise2===promise1); 		// true
promise2.then(function(value){
    console.log(value)      		  // 43
});

let promise3 = Promise.reject(promise1);   // Promise { <rejected> Promise { 43 } }
promise3.catch(function(value){
    console.log(value===promise1) 		 // true
    console.log(value) 					// Promise { 43 }
});


// 傳入Promise狀態為rejected
let promise4 = Promise.reject(44)

let promise5 = Promise.reject(promise4);
console.log(promise5); // Promise { <rejected> Promise { <rejected> 44 } }

promise5.catch(function(value){
    console.log(value===promise4) // true
    value.catch(function(v){
        console.log(v) 			// 44
    })
});

let promise6 = Promise.resolve(promise4); // Promise {<rejected>: 44}
console.log(promise6===promise4); // true
promise6.catch(function(v){
    console.log(v); // 44
});
// 傳入Promise狀態為pending
let promise7 = new Promise(function(resolve, reject){
    try{
        resolve();
    }catch (err){
        reject(err);
    }
});
promise7.then(function(){
    console.log('promise7 resolved');
},function(err){
    console.log('promise7 rejected');
});

let promise8 = Promise.resolve(promise7);
console.log(promise8===promise7); 			// true
promise8.then(function(value){
    console.log(value); 				  // undefined
})

let promise9 = Promise.reject(promise7);
console.log(promise9); 				// Promise { <rejected> Promise { undefined } }
promise9.catch(function(value){
    console.log(value===promise7); // true
    console.log(value); 		  // Promise { undefined }
})

(3) 引數為非Promise的Thenable

Promise.resolve() 與 Promise.reject() 都能接受非 Promise 的 thenable 作為引數。

當一個物件擁有一個能接受 resolve 與 reject 引數的 then() 方法,該物件就會被認為是一個非 Promise 的 thenable ,就像這樣:

let thenable = {
	then: function(resolve, reject) {
		resolve(42);
	}
};

當傳入了非 Promise 的 thenable 時,Promise.resolve()方法會將其轉為Promise物件,然後立即執行thenable物件的then()方法。如下所示:

let thenable = {
    then:function(resolve, reject){
        resolve(43);
    }
}

let p1 = Promise.resolve(thenable); // Promise { <pending> }
p1.then(function(value){
    console.log(value); // 43
});

thenable = {
    then:function(resolve, reject){
        reject(44);
    }
}
let p2 = Promise.resolve(thenable) // // Promise { <pending> }
p2.catch(function(value){
    console.log(value); // 44
});

// p1,p2 等同於 new Promise(function(resolve, reject){
//     try{
//         resolve(43);
//     } catch (err) {
//         reject(44)
//     }
// });

當傳入了非 Promise 的 thenable 時,Promise.reject()方法則會在thenable物件上包裝出一個Promise,狀態為rejected,呼叫該Promise的catch方法則其value引數為thenable物件。

let thenable = {
    then:function(resolve, reject){
        resolve(43);
    }
}

let p1 = Promise.reject(thenable); // Promise { <rejected> { then: [Function: then] } }
console.log(p1)
p1.catch(function(value){
    console.log(value=== thenable); // true
});

thenable = {
    then:function(resolve, reject){
        reject(44);
    }
}
let p2 = Promise.reject(thenable)    // Promise { <rejected> { then: [Function: then] } }
p2.catch(function(value){
    console.log(value===thenable); // true
});

(4) 引數為不具有then方法

如果引數是一個原始值,或者是一個不具有then()方法的物件,則Promise.resolve()方法返回一個新的 Promise 物件,狀態為resolvedPromise.reject()方法返回一個狀態為rejected的Promise物件。

4. 單非同步響應

Promise例項具有3個原型方法,用以平時處理單個非同步操作,如下所示:

  • Promise.prototype.then
  • Promise.prototype.catch
  • Promise.prototype.finally

4.1 Promise.prototype.then

then方法是定義在原型物件Promise.prototype上的。作用是為 Promise 例項新增狀態改變時的回撥函式。前面說過,then方法的第一個引數是resolved狀態的回撥函式,第二個引數是rejected狀態的回撥函式,它們都是可選的。

then方法返回的是一個新的Promise例項(注意,不是原來那個Promise例項)。因此可以採用鏈式寫法,即then方法後面再呼叫另一個then方法。採用鏈式的then,可以指定一組按照次序呼叫的回撥函式。這時,前一個回撥函式,有可能返回的還是一個Promise物件(即有非同步操作),這時後一個回撥函式,就會等待該Promise物件的狀態發生變化,才會被呼叫。

4.2 Promise.prototype.catch

Promise.prototype.catch()方法等同於.then(null, rejection).then(undefined, rejection),用於指定發生錯誤時的回撥函式。所以catch方法返回的也是一個新的Promise例項,也可以採用鏈式寫法。

如果非同步操作丟擲錯誤,Promise狀態變為rejected,就會呼叫catch()方法指定的回撥函式,處理這個錯誤。另外,then()方法指定的回撥函式,如果執行中丟擲錯誤,也會被catch()方法捕獲。

Promise 物件的錯誤具有“冒泡”性質,會一直向後傳遞,直到被捕獲為止。也就是說,錯誤總是會被下一個catch語句捕獲。跟傳統的try/catch程式碼塊不同的是,如果沒有使用catch()方法指定錯誤處理的回撥函式Promise 物件丟擲的錯誤不會傳遞到外層程式碼,即不會有任何反應。

一般來說,不要在then()方法裡面定義 Reject 狀態的回撥函式(即then的第二個引數),總是使用catch方法。

// bad
promise
  .then(function(data) {
    // success
  }, function(err) {
    // error
  });

// good
promise
  .then(function(data) { //cb
    // success
  })
  .catch(function(err) {
    // error
  });

4.3 Promise.prototype.finally

finally()方法用於指定不管 Promise 物件最後狀態如何,都會執行的操作。該方法是 ES2018 引入標準的。finally方法的回撥函式不接受任何引數,這意味著沒有辦法知道,前面的 Promise 狀態到底是fulfilled還是rejected。這表明,finally方法裡面的操作,應該是與狀態無關的,不依賴於 Promise 的執行結果。

promise
.then(result => {···})
.catch(error => {···})
.finally(() => {···});

上面程式碼中,不管promise最後的狀態,在執行完thencatch指定的回撥函式以後,都會執行finally方法指定的回撥函式。

finally本質上是then方法的特例。

promise
.finally(() => {
  // 語句
});

// 等同於
promise
.then(
  result => {
    // 語句
    return result;
  },
  error => {
    // 語句
    throw error;
  }
);

上面程式碼中,如果不使用finally方法,同樣的語句需要為成功和失敗兩種情況各寫一次。有了finally方法,則只需要寫一次。

它的實現也很簡單。

Promise.prototype.finally = function (callback) {
  let P = this.constructor;
  return this.then(
    value  => P.resolve(callback()).then(() => value),
    reason => P.resolve(callback()).then(() => { throw reason })
  );
};

上面程式碼中,不管前面的 Promise 是fulfilled還是rejected,都會執行回撥函式callback

5. 並行非同步響應

JavaScript中Promise有如下方法可並行處理多個非同步操作:

  • Promise.all()
  • Promise.race()
  • Promise.allSettled()
  • Promise.any()

5.1 Promise.all()

Promise.all()方法接收單個可迭代物件(如陣列)作為引數,可迭代物件的元素都為Promise例項,若不是則呼叫Promise.resolve()方法將其轉化為Promise例項,再進一步處理。

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

p的狀態由p1p2p3決定,分成兩種情況。

(1)只有p1p2p3的狀態都變成fulfilledp的狀態才會變成fulfilled,此時p1p2p3的返回值組成一個陣列,傳遞給p的回撥函式。

(2)只要p1p2p3之中有一個被rejectedp的狀態就變成rejected,此時第一個被reject的例項的返回值,會傳遞給p的回撥函式。

5.2 Promise.race()

Promise.race()也接受一個包含需監視的 Promise 的可迭代物件,並返回一個新的 Promise。和Promise.all()方法不同的是,一旦來源Promise中有一個被完成,所返回的Promise就會立刻完成,那個率先完成得Promsie的返回值會傳遞給返回的Promise物件。

let p1 = Promise.resolve(42);
let p2 = new Promise(function(resolve, reject) {
	resolve(43);
});
let p3 = new Promise(function(resolve, reject) {
	resolve(44);
});
let p4 = Promise.race([p1, p2, p3]);
p4.then(function(value) {
	console.log(value); // 42
});

5.3 Promise.allSettled()

Promise.allSettled()方法接受一組 Promise 例項作為引數,包裝成一個新的 Promise 例項。只有等到所有這些引數例項都返回結果,不管是fulfilled還是rejected,包裝例項才會結束。該方法由 ES2020 引入。

該方法返回的新的 Promise 例項,一旦結束,狀態總是fulfilled,不會變成rejected。狀態變成fulfilled後,Promise 的監聽函式接收到的引數是一個陣列,每個成員對應一個傳入Promise.allSettled()的 Promise 例項。

let p1 = Promise.resolve(42);
let p2 = new Promise(function(resolve, reject) {
	resolve(43);
});
let p3 = new Promise(function(resolve, reject) {
	reject(44);
});
let p4 = Promise.allSettled([p1, p2, p3]);
p4.then(function(value) {
  console.log(value);
  console.log(p4);
});

0: {status: "fulfilled", value: 42}
1: {status: "fulfilled", value: 43}
2: {status: "rejected", reason: 44}

5.4 Promise.any()

ES2021 引入了Promise.any()方法。該方法接受一組 Promise 例項作為引數,包裝成一個新的 Promise 例項返回。只要引數例項有一個變成fulfilled狀態,

const p = Promise.any([p1,p2,p3]);

p的狀態由p1p2p3決定,分成兩種情況。

(1)只要p1p2p3的狀態任意一個變成fulfilledp的狀態就變成fulfilled,並且首個fulfilled的Promise的返回值傳遞給p的回撥函式。

(2)只有p1p2p3全部被rejectedp的狀態就變成rejected,並且丟擲一個AggregateError 錯誤。它相當於一個陣列,每個成員對應一個被rejected的操作所丟擲的錯誤。

let p1 = Promise.resolve(42);
let p2 = new Promise(function(resolve, reject) {
	resolve(43);
});
let p3 = new Promise(function(resolve, reject) {
	reject(44);
});
let p = Promise.any([p1, p2, p3]);
p.then(function(value) {
  console.log(value);
  console.log(p);
});

results

let p1 = Promise.reject(42);
let p2 = new Promise(function(resolve, reject) {
	reject(43);
});
let p3 = new Promise(function(resolve, reject) {
	reject(44);
});
let p = Promise.any([p1, p2, p3]);
p.then(function(value) {
  console.log(value);
});

results:

6.小結

Promise被設計用於改善JS中的非同步程式設計,與事件和回撥函式對比,在非同步操作中給我們提供了更多的控制權與組合型。Promise具有三種狀態:掛起、已完成、已拒絕。一個Promise起始於掛起態,並在成功時轉為完成態,或在失敗時轉為拒絕態。在這兩種情況下,處理函式都能被新增以表明Promise何時被解決。then()方法允許你繫結完成處理函式與拒絕處理函式,而 catch()方法則只允許你繫結拒絕處理函式。並且Promise能用多種方式串聯在一起,並在它們之間傳遞資訊。每個對 then() 的呼叫都建立並返回了一個新的Promise,在前一個Promise被決議時,新Promise也會被決議。Promise鏈可被用於觸發對一系列非同步事件的響應。除此之外,我們能夠使用Promsie.all()/Promise.race()/Promise.allSettled()/Promise.any()同時監聽多個Promise,並行性相應的響應。

相關文章