讓我們回顧一下在章節 簡介:回呼函數 中提到的問題:我們有一連串的非同步任務要依序執行,例如載入腳本。我們要如何編寫它?
Promise 提供了幾個食譜來執行此操作。
在本章中,我們將介紹 Promise 串接。
它看起來像這樣
new Promise(function(resolve, reject) {
setTimeout(() => resolve(1), 1000); // (*)
}).then(function(result) { // (**)
alert(result); // 1
return result * 2;
}).then(function(result) { // (***)
alert(result); // 2
return result * 2;
}).then(function(result) {
alert(result); // 4
return result * 2;
});
這個想法是將結果傳遞給 .then
處理函式的串接。
此處流程為
- 初始 Promise 在 1 秒鐘內解析
(*)
, - 然後呼叫
.then
處理函式(**)
,它反過來建立一個新的 Promise(使用2
值解析)。 - 下一個
then
(***)
取得前一個的結果,處理它(加倍)並將其傳遞給下一個處理函式。 - …依此類推。
由於結果會沿著處理函式串接傳遞,因此我們可以看到一連串的 alert
呼叫:1
→ 2
→ 4
。
整個運作方式是因為每個呼叫 .then
都會傳回一個新的 Promise,因此我們可以在它上面呼叫下一個 .then
。
當處理函式傳回一個值時,它會成為該 Promise 的結果,因此下一個 .then
會使用它來呼叫。
一個經典的新手錯誤:技術上我們也可以在一個 Promise 中加入許多 .then
。這不是串接。
例如
let promise = new Promise(function(resolve, reject) {
setTimeout(() => resolve(1), 1000);
});
promise.then(function(result) {
alert(result); // 1
return result * 2;
});
promise.then(function(result) {
alert(result); // 1
return result * 2;
});
promise.then(function(result) {
alert(result); // 1
return result * 2;
});
我們在此所做的只是在一個 Promise 中加入幾個處理函式。它們不會彼此傳遞結果;相反地,它們會獨立處理結果。
以下是圖片(將它與上方的串接比較)
在同一個 Promise 上的所有 .then
都會取得相同的結果,也就是該 Promise 的結果。因此在上述程式碼中,所有 alert
都會顯示相同的值:1
。
在實際應用中,我們很少需要在一個 Promise 中使用多個處理函式。串接的使用頻率高得多。
傳回 Promise
在 .then(handler)
中使用的處理函式可能會建立並傳回一個 Promise。
在這種情況下,後續的處理函式會等到它解決後,再取得它的結果。
例如
new Promise(function(resolve, reject) {
setTimeout(() => resolve(1), 1000);
}).then(function(result) {
alert(result); // 1
return new Promise((resolve, reject) => { // (*)
setTimeout(() => resolve(result * 2), 1000);
});
}).then(function(result) { // (**)
alert(result); // 2
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 2), 1000);
});
}).then(function(result) {
alert(result); // 4
});
此處第一個 .then
顯示 1
,並在 (*)
行中傳回 new Promise(…)
。一秒鐘後,它會解析,而結果(resolve
的引數,此處為 result * 2
)會傳遞給第二個 .then
的處理函式。該處理函式在 (**)
行中,它顯示 2
並執行相同動作。
因此,輸出與前一個範例相同:1 → 2 → 4,但現在 alert
呼叫之間有 1 秒鐘的延遲。
傳回 Promise 讓我們可以建立非同步動作的串接。
範例:loadScript
我們使用在上一章定義的 Promise 化 loadScript
功能,依序載入腳本
loadScript("/article/promise-chaining/one.js")
.then(function(script) {
return loadScript("/article/promise-chaining/two.js");
})
.then(function(script) {
return loadScript("/article/promise-chaining/three.js");
})
.then(function(script) {
// use functions declared in scripts
// to show that they indeed loaded
one();
two();
three();
});
使用箭頭函式可以讓這段程式碼更簡短
loadScript("/article/promise-chaining/one.js")
.then(script => loadScript("/article/promise-chaining/two.js"))
.then(script => loadScript("/article/promise-chaining/three.js"))
.then(script => {
// scripts are loaded, we can use functions declared there
one();
two();
three();
});
這裡每個 loadScript
呼叫會傳回一個 Promise,而下一個 .then
會在它解析時執行。然後它會啟動下一個腳本的載入。因此腳本會一個接著一個載入。
我們可以將更多非同步動作加入鏈中。請注意,程式碼仍然是「扁平的」— 它會向下延伸,而不是向右延伸。沒有「厄運金字塔」的跡象。
技術上來說,我們可以直接將 .then
加入每個 loadScript
,如下所示
loadScript("/article/promise-chaining/one.js").then(script1 => {
loadScript("/article/promise-chaining/two.js").then(script2 => {
loadScript("/article/promise-chaining/three.js").then(script3 => {
// this function has access to variables script1, script2 and script3
one();
two();
three();
});
});
});
這段程式碼會執行相同的動作:依序載入 3 個腳本。但它會「向右延伸」。因此我們會遇到與回呼函式相同的問題。
開始使用 Promise 的人有時不知道鏈結,因此他們會這樣寫。一般來說,建議使用鏈結。
有時直接寫 .then
是可以的,因為巢狀函式可以存取外部範圍。在上面的範例中,最巢狀的回呼函式可以存取所有變數 script1
、script2
、script3
。但這是一個例外,而不是規則。
精確來說,處理函式傳回的可能不完全是 Promise,而是所謂的「thenable」物件— 一個具有 .then
方法的任意物件。它會像 Promise 一樣被處理。
這個概念是第三方程式庫可以實作自己的「與 Promise 相容」物件。它們可以有擴充的方法集,但也能與原生 Promise 相容,因為它們實作了 .then
。
以下是 thenable 物件的範例
class Thenable {
constructor(num) {
this.num = num;
}
then(resolve, reject) {
alert(resolve); // function() { native code }
// resolve with this.num*2 after the 1 second
setTimeout(() => resolve(this.num * 2), 1000); // (**)
}
}
new Promise(resolve => resolve(1))
.then(result => {
return new Thenable(result); // (*)
})
.then(alert); // shows 2 after 1000ms
JavaScript 會在 (*)
行檢查 .then
處理函式傳回的物件:如果它有一個名為 then
的可呼叫方法,它就會呼叫該方法,提供原生函式 resolve
、reject
作為引數(類似執行器),然後等待其中一個被呼叫。在上面的範例中,resolve(2)
會在 1 秒後被呼叫 (**)
。然後結果會傳遞到鏈中的下一個位置。
這個功能讓我們可以將自訂物件整合到 Promise 鏈中,而不需要從 Promise
繼承。
較大的範例:擷取
在前端程式設計中,承諾通常用於網路要求。因此,讓我們看一個延伸的範例。
我們將使用 擷取 方法從遠端伺服器載入使用者的資訊。它有許多選用參數,在 獨立章節 中有說明,但基本語法相當簡單
let promise = fetch(url);
這會對 url
提出網路要求,並在遠端伺服器回應標頭時傳回承諾,但在完整回應下載之前。
若要讀取完整回應,我們應該呼叫方法 response.text()
:它會傳回一個承諾,在從遠端伺服器下載完整文字時,以該文字為結果來解決。
以下程式碼會對 user.json
提出要求,並從伺服器載入其文字
fetch('/article/promise-chaining/user.json')
// .then below runs when the remote server responds
.then(function(response) {
// response.text() returns a new promise that resolves with the full response text
// when it loads
return response.text();
})
.then(function(text) {
// ...and here's the content of the remote file
alert(text); // {"name": "iliakan", "isAdmin": true}
});
從 fetch
傳回的 response
物件也包含方法 response.json()
,它會讀取遠端資料並將其解析為 JSON。在我們的案例中,這甚至更方便,因此讓我們切換到它。
我們也會使用箭頭函式以簡潔
// same as above, but response.json() parses the remote content as JSON
fetch('/article/promise-chaining/user.json')
.then(response => response.json())
.then(user => alert(user.name)); // iliakan, got user name
現在讓我們對載入的使用者做點事。
例如,我們可以對 GitHub 提出另一個要求,載入使用者個人資料並顯示頭像
// Make a request for user.json
fetch('/article/promise-chaining/user.json')
// Load it as json
.then(response => response.json())
// Make a request to GitHub
.then(user => fetch(`https://api.github.com/users/${user.name}`))
// Load the response as json
.then(response => response.json())
// Show the avatar image (githubUser.avatar_url) for 3 seconds (maybe animate it)
.then(githubUser => {
let img = document.createElement('img');
img.src = githubUser.avatar_url;
img.className = "promise-avatar-example";
document.body.append(img);
setTimeout(() => img.remove(), 3000); // (*)
});
程式碼有效;請參閱有關詳細資料的註解。然而,其中存在潛在問題,這是初學承諾的人常犯的錯誤。
查看第 (*)
行:我們如何在頭像顯示完畢並移除之後做點事?例如,我們想要顯示一個表單以編輯該使用者或其他事項。目前,沒有辦法。
若要使鏈可延伸,我們需要傳回一個承諾,在頭像顯示完畢時解決。
像這樣
fetch('/article/promise-chaining/user.json')
.then(response => response.json())
.then(user => fetch(`https://api.github.com/users/${user.name}`))
.then(response => response.json())
.then(githubUser => new Promise(function(resolve, reject) { // (*)
let img = document.createElement('img');
img.src = githubUser.avatar_url;
img.className = "promise-avatar-example";
document.body.append(img);
setTimeout(() => {
img.remove();
resolve(githubUser); // (**)
}, 3000);
}))
// triggers after 3 seconds
.then(githubUser => alert(`Finished showing ${githubUser.name}`));
也就是說,第 (*)
行中的 .then
處理常式現在傳回 new Promise
,它只在 setTimeout
(**)
中呼叫 resolve(githubUser)
之後才解決。鏈中的下一個 .then
將等待它。
作為一個良好的做法,非同步動作應該總是傳回一個承諾。這使得在它之後規劃動作成為可能;即使我們現在不打算延伸鏈,我們可能稍後需要它。
最後,我們可以將程式碼拆分為可重複使用的函式
function loadJson(url) {
return fetch(url)
.then(response => response.json());
}
function loadGithubUser(name) {
return loadJson(`https://api.github.com/users/${name}`);
}
function showAvatar(githubUser) {
return new Promise(function(resolve, reject) {
let img = document.createElement('img');
img.src = githubUser.avatar_url;
img.className = "promise-avatar-example";
document.body.append(img);
setTimeout(() => {
img.remove();
resolve(githubUser);
}, 3000);
});
}
// Use them:
loadJson('/article/promise-chaining/user.json')
.then(user => loadGithubUser(user.name))
.then(showAvatar)
.then(githubUser => alert(`Finished showing ${githubUser.name}`));
// ...
摘要
如果 .then
(或 catch/finally
,沒關係)處理常式傳回一個承諾,鏈中的其餘部分會等到它解決。當它解決時,它的結果(或錯誤)會進一步傳遞。
以下是完整畫面
留言
<code>
標籤,對於多行 - 請將它們包裝在<pre>
標籤中,對於超過 10 行 - 請使用沙盒 (plnkr、jsbin、codepen…)