使用Promise解決多層非同步呼叫的簡單學習

knightingal發表於2019-03-04

前言

第一次接觸到Promise這個東西,是2012年微軟釋出Windows8作業系統後抱著作死好奇的心態研究用html5寫Metro應用的時候。當時配合html5提供的WinJS庫裡面的非同步介面全都是Promise形式,這對那時候剛剛畢業一點javascript基礎都沒有的我而言簡直就是天書。我當時想的是,微軟又在腦洞大開的瞎搗鼓了。

結果沒想到,到了2015年,Promise居然寫進ES6標準裡面了。而且一項調查顯示,js程式設計師們用這玩意用的還挺high。

諷刺的是,作為早在2012年就在Metro應用開發介面裡面廣泛使用Promise的微軟,其自家瀏覽器IE直到2015年壽終正寢了都還不支援Promise,看來微軟不是沒有這個技術,而是真的對IE放棄治療了。。。

現在回想起來,當時看到Promise最頭疼的,就是初學者看起來匪夷所思,也是最被js程式設計師廣為稱道的特性:then函式呼叫鏈。

then函式呼叫鏈,從其本質上而言,就是對多個非同步過程的依次呼叫,本文就從這一點著手,對Promise這一特性進行研究和學習。

Promise解決的問題

考慮如下場景,函式延時2秒之後列印一行日誌,再延時3秒列印一行日誌,再延時4秒列印一行日誌,這在其他的程式語言當中是非常簡單的事情,但是到了js裡面就比較費勁,程式碼大約會寫成下面的樣子:

var myfunc = function() {	
	setTimeout(function() {
		console.log("log1");
		setTimeout(function() {
			console.log("log2");
			setTimeout(function() {
				console.log("log3");
			}, 4000);
		}, 3000); 
	}, 2000);
}
複製程式碼

由於巢狀了多層回撥結構,這裡形成了一個典型的金字塔結構。如果業務邏輯再複雜一些,就會變成令人聞風喪膽的回撥地獄。

如果意識比較好,知道提煉出簡單的函式,那麼程式碼差不多是這個樣子:

var func1 = function() {
	setTimeout(func2, 2000);
};

var func2 = function() {
	console.log("log1");
	setTimeout(func3, 3000);
};

var func3 = function() {
	console.log("log2");
	setTimeout(func4, 4000);
};

var func4 = function() {
	console.log("log3");
};
複製程式碼

這樣看起來稍微好一點了,但是總覺得有點怪怪的。。。好吧,其實我js水平有限,說不上來為什麼這樣寫不好。如果你知道為什麼這樣寫不太好所以發明了Promise,請告訴我。

現在讓我們言歸正傳,說說Promise這個東西。

Promise的描述

這裡請允許我引用MDN對Promise的描述:

Promise 物件用於延遲(deferred) 計算和非同步(asynchronous ) 計算.。一個Promise物件代表著一個還未完成,但預期將來會完成的操作。

Promise 物件是一個返回值的代理,這個返回值在promise物件建立時未必已知。它允許你為非同步操作的成功或失敗指定處理方法。 這使得非同步方法可以像同步方法那樣返回值:非同步方法會返回一個包含了原返回值的 promise 物件來替代原返回值。

Promise物件有以下幾種狀態:

  • pending: 初始狀態, 非 fulfilled 或 rejected。
  • fulfilled: 成功的操作。
  • rejected: 失敗的操作。

pending狀態的promise物件既可轉換為帶著一個成功值的fulfilled 狀態,也可變為帶著一個失敗資訊的 rejected 狀態。當狀態發生轉換時,promise.then繫結的方法(函式控制程式碼)就會被呼叫。(當繫結方法時,如果 promise物件已經處於 fulfilled 或 rejected 狀態,那麼相應的方法將會被立刻呼叫, 所以在非同步操作的完成情況和它的繫結方法之間不存在競爭條件。)

更多關於Promise的描述和示例可以參考MDN的Promise條目,或者MSDN的Promise條目。

嘗試使用Promise解決我們的問題

基於以上對Promise的瞭解,我們知道可以使用它來解決多層回撥巢狀後的程式碼蠢笨難以維護的問題。關於Promise的語法和引數上面給出的兩個連結已經說的很清楚了,這裡不重複,直接上程式碼。

我們先來嘗試一個比較簡單的情況,只執行一次延時和回撥:

new Promise(function(res, rej) {
	console.log(Date.now() + " start setTimeout");
	setTimeout(res, 2000);
}).then(function() {
	console.log(Date.now() + " timeout call back");
});
複製程式碼

看起來和MSDN裡的示例也沒什麼區別,執行結果如下:

$ node promisTest.js
1450194136374 start setTimeout
1450194138391 timeout call back
複製程式碼

那麼如果我們要再做一個延時呢,那麼我可以這樣寫:

new Promise(function(res, rej) {
	console.log(Date.now() + " start setTimeout 1");
	setTimeout(res, 2000);
}).then(function() {
	console.log(Date.now() + " timeout 1 call back");
	new Promise(function(res, rej) {
		console.log(Date.now() + " start setTimeout 2");
		setTimeout(res, 3000);
	}).then(function() {
		console.log(Date.now() + " timeout 2 call back");
	})
});
複製程式碼

似乎也能正確執行:

$ node promisTest.js
1450194338710 start setTimeout 1
1450194340720 timeout 1 call back
1450194340720 start setTimeout 2
1450194343722 timeout 2 call back
複製程式碼

不過程式碼看起來蠢萌蠢萌的是不是,而且隱約又在搭金字塔了。這和引入Promise的目的背道而馳。

那麼問題出在哪呢?正確的姿勢又是怎樣的?

答案藏在then函式以及then函式的onFulfilled(或者叫onCompleted)回撥函式的返回值裡面。

首先明確的一點是,then函式會返回一個新的Promise變數,你可以再次呼叫這個新的Promise變數的then函式,像這樣:

new Promise(...).then(...)
	.then(...).then(...).then(...)...
複製程式碼

then函式返回的是什麼樣的Promies,取決於onFulfilled回撥的返回值。

事實上,onFulfilled可以返回一個普通的變數,也可以是另一個Promise變數。

如果onFulfilled返回的是一個普通的值,那麼then函式會返回一個預設的Promise變數。執行這個Promise的then函式會使Promise立即被滿足,執行onFulfilled函式,而這個onFulfilled的入參,即是上一個onFulfilled的返回值。

而如果onFulfilled返回的是一個Promise變數,那個這個Promise變數就會作為then函式的返回值。

關於then函式和onFulfilled函式的返回值的這一系列設定,MDN和MSDN上的文件都沒有明確的正面描述,至於ES6官方文件ECMAScript 2015 (6th Edition, ECMA-262)。。。我的水平有限實在看不懂,如果哪位高手能解釋清楚官方文件裡面對著兩個返回值的描述,請一定留言指教!!!

所以以上為我的自由發揮,語言組織的有點拗口,上程式碼看一下大家就明白了。

首先是返回普通變數的情況:

new Promise(function(res, rej) {
	console.log(Date.now() + " start setTimeout 1");
	setTimeout(res, 2000);
}).then(function() {
	console.log(Date.now() + " timeout 1 call back");
	return 1024;
}).then(function(arg) {
	console.log(Date.now() + " last onFulfilled return " + arg);	
});
複製程式碼

以上程式碼執行結果為:

$ node promisTest.js
1450277122125 start setTimeout 1
1450277124129 timeout 1 call back
1450277124129 last onFulfilled return 1024
複製程式碼

有點意思對不對,但這不是關鍵。關鍵是onFulfilled函式返回一個Promise變數可以使我們很方便的連續呼叫多個非同步過程。比如我們可以這樣來嘗試連續做兩個延時操作:

new Promise(function(res, rej) {
	console.log(Date.now() + " start setTimeout 1");
	setTimeout(res, 2000);
}).then(function() {
	console.log(Date.now() + " timeout 1 call back");
	return new Promise(function(res, rej) {
		console.log(Date.now() + " start setTimeout 2");
		setTimeout(res, 3000);
	});
}).then(function() {
	console.log(Date.now() + " timeout 2 call back");
});
複製程式碼

執行結果如下:

$ node promisTest.js
1450277510275 start setTimeout 1
1450277512276 timeout 1 call back
1450277512276 start setTimeout 2
1450277515327 timeout 2 call back
複製程式碼

如果覺得這也沒什麼了不起,那再多來幾次也不在話下:

new Promise(function(res, rej) {
	console.log(Date.now() + " start setTimeout 1");
	setTimeout(res, 2000);
}).then(function() {
	console.log(Date.now() + " timeout 1 call back");
	return new Promise(function(res, rej) {
		console.log(Date.now() + " start setTimeout 2");
		setTimeout(res, 3000);
	});
}).then(function() {
	console.log(Date.now() + " timeout 2 call back");
	return new Promise(function(res, rej) {
		console.log(Date.now() + " start setTimeout 3");
		setTimeout(res, 4000);
	});
}).then(function() {
	console.log(Date.now() + " timeout 3 call back");
	return new Promise(function(res, rej) {
		console.log(Date.now() + " start setTimeout 4");
		setTimeout(res, 5000);
	});
}).then(function() {
	console.log(Date.now() + " timeout 4 call back");
});
複製程式碼
$ node promisTest.js
1450277902714 start setTimeout 1
1450277904722 timeout 1 call back
1450277904724 start setTimeout 2
1450277907725 timeout 2 call back
1450277907725 start setTimeout 3
1450277911730 timeout 3 call back
1450277911730 start setTimeout 4
1450277916744 timeout 4 call back
複製程式碼

可以看到,多個延時的回撥函式被有序的排列下來,並沒有出現喜聞樂見的金字塔狀結構。雖然程式碼裡面呼叫的都是非同步過程,但是看起來就像是全部由同步過程構成的一樣。這就是Promise帶給我們的好處。

如果你有把囉嗦的程式碼提煉成單獨函式的好習慣,那就更加畫美不看了:

function timeout1() {
	return new Promise(function(res, rej) {
		console.log(Date.now() + " start timeout1");
		setTimeout(res, 2000);
	});
}

function timeout2() {
	return new Promise(function(res, rej) {
		console.log(Date.now() + " start timeout2");
		setTimeout(res, 3000);
	});
}

function timeout3() {
	return new Promise(function(res, rej) {
		console.log(Date.now() + " start timeout3");
		setTimeout(res, 4000);
	});
}

function timeout4() {
	return new Promise(function(res, rej) {
		console.log(Date.now() + " start timeout4");
		setTimeout(res, 5000);
	});
}

timeout1()
	.then(timeout2)
	.then(timeout3)
	.then(timeout4)
	.then(function() {
		console.log(Date.now() + " timout4 callback");
	});
複製程式碼
$ node promisTest.js
1450278983342 start timeout1
1450278985343 start timeout2
1450278988351 start timeout3
1450278992356 start timeout4
1450278997370 timout4 callback
複製程式碼

接下來我們可以再繼續研究一下onFulfilled函式傳入入參的問題。

我們已經知道,如果上一個onFulfilled函式返回了一個普通的值,那麼這個值為作為這個onFulfilled函式的入參;那麼如果上一個onFulfilled返回了一個Promise變數,這個onFulfilled的入參又來自哪裡?

答案是,這個onFulfilled函式的入參,是上一個Promise中呼叫resolve函式時傳入的值。

跳躍的有點大一時間無法接受對不對,讓我們來好好縷一縷。

首先,Promise.resolve這個函式是什麼,用MDN上面文鄒鄒的說法

用成功值value解決一個Promise物件。如果該value為可繼續的(thenable,即帶有then方法),返回的Promise物件會“跟隨”這個value,採用這個value的最終狀態;否則的話返回值會用這個value滿足(fullfil)返回的Promise物件。
複製程式碼

簡而言之,這就是非同步呼叫成功情況下的回撥。

我們來看看普通的非同步介面中,成功情況的回撥是什麼樣的,就拿nodejs的上的fs.readFile(file[, options], callback)來說,它的典型呼叫例子如下

fs.readFile(`/etc/passwd`, function (err, data) {
  if (err) throw err;
  console.log(data);
});
複製程式碼

因為對於fs.readFile這個函式而言,無論成功還是失敗,它都會呼叫callback這個回撥函式,所以這個回撥接受兩個入參,即失敗時的異常描述err和成功時的返回結果data

那麼假如我們用Promise來重構這個讀取檔案的例子,我們應該怎麼寫呢?

首先是封裝fs.readFile函式:

function readFile(fileName) {
	return new Promise(function(resolve, reject) {
		fs.readFile(fileName, function (err, data) {
			if (err) {
				reject(err);
			} else {
				resolve(data);
			}
		});
	});
}
複製程式碼

其次是呼叫:

readFile(`theFile.txt`).then(
	function(data) {
		console.log(data);
	}, 
	function(err) {
		throw err;
	}	
);
複製程式碼

想象一下,在其他語言的讀取檔案的同步呼叫介面的裡面,檔案的內容通常是放在哪裡?函式返回值對不對!答案出來了,這個resolve的入參是什麼?就是非同步呼叫成功情況下的返回值。

有了這個概念之後,我們就不難理解“onFulfilled函式的入參,是上一個Promise中呼叫resolve函式時傳入的值”這件事了。因為onFulfilled的任務,就是對上一個非同步呼叫成功後的結果做處理的。

哎終於理順了。。。

總結

下面請允許我用一段程式碼對本文講解到的要點進行總結:

function callp1() {
	console.log(Date.now() + " start callp1");
	return new Promise(function(res, rej) {
		setTimeout(res, 2000);
	});
}

function callp2() {
	console.log(Date.now() + " start callp2");
	return new Promise(function(res, rej) {
		setTimeout(function() {
			res({arg1: 4, arg2: "arg2 value"});
		}, 3000);
	});
}

function callp3(arg) {
	console.log(Date.now() + " start callp3 with arg = " + arg);
	return new Promise(function(res, rej) {
		setTimeout(function() {
			res("callp3");
		}, arg * 1000);
	});
}

callp1().then(function() {
	console.log(Date.now() + " callp1 return");
	return callp2();
}).then(function(ret) {
	console.log(Date.now() + " callp2 return with ret value = " + JSON.stringify(ret));
	return callp3(ret.arg1);
}).then(function(ret) {
	console.log(Date.now() + " callp3 return with ret value = " + ret);
})
複製程式碼
$ node promisTest.js
1450191479575 start callp1
1450191481597 callp1 return
1450191481599 start callp2
1450191484605 callp2 return with ret value = {"arg1":4,"arg2":"arg2 value"}
1450191484605 start callp3 with arg = 4
1450191488610 callp3 return with ret value = callp3
複製程式碼

相關文章