Promise 可以簡化延遲和非同步運算。承諾代表尚未完成的作業。
開發人員請做好準備,掌握網頁開發史上的關鍵時刻。
[鼓聲響開始]
Promise 已加入 JavaScript!
[煙火爆炸、從空中閃耀的紙雨,天生大爆炸]
目前您的所屬類別如下:
- 您四周的人都是歡呼,但你不確定這是什麼東西。說不定,你甚至不知道什麼是「承諾」。你可能會害怕,但花俏的紙張都關掉肩膀。如果是,請不用擔心,我花了很久的時間來研究為什麼應該關心這件事。您可能會想從開始著手。
- 你打出空中!剛剛好嗎?您之前曾使用過這些 Promise,但卻擔心所有實作的 API 略有不同。官方 JavaScript 版本的 API 為何?建議您從術語著手。
- 您深知這件事情,也要對那些熱血沸騰的人事看,他們都知道消息。請花點時間親自進行,並參閱 API 參考資料。
瀏覽器支援和 polyfill
為了讓缺少完整實作承諾的瀏覽器達到規格法規遵循,或對其他瀏覽器和 Node.js 提供承諾,請參閱 polyfill (20 gzip 壓縮)。
有什麼煩惱嗎?
JavaScript 是單一執行緒,意味著兩部分指令碼無法同時執行,必須依序執行。在瀏覽器中,JavaScript 會共用執行緒,但這些載入量會因瀏覽器而異。但 JavaScript 通常和繪圖、更新樣式及處理使用者動作 (例如醒目顯示文字以及與表單控制項互動) 位於同一個佇列中。發生上述任何一項活動時,其他活動會延遲顯示。
人在說,「多執行緒」。您可以使用多指輸入文字 同時按住會話群組我們唯一需要處理的封鎖函式就是打噴機,在打散期間,所有目前活動都必須暫停。這很令人困擾 尤其是在開車和嘗試交談時您不想編寫會搞笑的程式碼
您可能已運用事件和回呼解決這個問題。以下為這類活動:
var img1 = document.querySelector('.img-1');
img1.addEventListener('load', function() {
// woo yey image loaded
});
img1.addEventListener('error', function() {
// argh everything's broken
});
這個結果完全沒有洩漏。取得圖片並新增幾個事件監聽器後,JavaScript 就會停止執行,直到呼叫其中一個事件監聽器為止。
遺憾的是,在上述範例中,事件可能在我們開始監聽前發生,因此我們需要使用圖片的「complete」屬性來解決這個問題:
var img1 = document.querySelector('.img-1');
function loaded() {
// woo yey image loaded
}
if (img1.complete) {
loaded();
}
else {
img1.addEventListener('load', loaded);
}
img1.addEventListener('error', function() {
// argh everything's broken
});
如果在我們聽取這些圖片之前,無法擷取出原本發生錯誤的圖片,但 DOM 無法擷取這類圖片。同時也載入一張映像檔如果我們想要知道一組圖片何時載入,情況會變得更加複雜。
活動有時不見得
事件適用於在相同物件 (keyup
、touchstart
等) 上可能多次發生的事件。這些事件非常適合在附加事件監聽器之前發生的情況。但提到非同步成功/失敗情形時,您會需要以下程式碼:
img1.callThisIfLoadedOrWhenLoaded(function() {
// loaded
}).orIfFailedCallThis(function() {
// failed
});
// and…
whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
// all loaded
}).orIfSomeFailedCallThis(function() {
// one or more failed
});
這樣的命名方式更好,但名稱更貼合標準。如果 HTML 圖片元素具有傳回承諾的「就緒」方法,我們就可以這麼做:
img1.ready()
.then(function() {
// loaded
}, function() {
// failed
});
// and…
Promise.all([img1.ready(), img2.ready()])
.then(function() {
// all loaded
}, function() {
// one or more failed
});
基本上,保證有點像事件監聽器,除了:
- 一個承諾只能成功或出現一次。無法成功或失敗兩次,也無法從成功切換為失敗,反之亦然。
- 如果承諾成功或失敗,而您之後又新增成功/失敗回呼,即使事件較早發生,系統仍會呼叫正確的回呼。
這對於非同步成功/失敗作業非常實用,因為您比較不需要確切的可用時間,也較有興趣回應結果。
承諾術語
Domenic Denicola 證明已閱讀本文的初稿,並針對術語將「F」評為「F」。他把我保持拘留,強制我寫了 States and Fates 100 次, 也寫了一篇遺忘的信給爸媽。儘管如此,我還是混合了許多術語,但基本知識如下:
例如:
- fulfill - 與承諾內容相關的動作成功
- rejected:無法執行與承諾相關的動作
- 待處理 - 尚未出貨或遭到拒絕
- settled - 已完成或遭到拒絕
規格也使用「thenable」一詞來描述與承諾類似的物件,且該物件具有 then
方法。這個字詞提醒我前英格蘭足球經理 Terry Venables,我會盡量少用它。
Promise 導入 JavaScript!
Promise 出現在程式庫中已有一段時間,例如:
上述和 JavaScript 承諾會共用稱為 Promises/A+ 的常見標準化行為。如果您是 jQuery 使用者,也會有類似的名為「Deferreds」行為。不過,「延遲」不符合 Promise/A+ 規範,因此會略有不同且較不實用,因此請特別留意。jQuery 也提供Promise 類型,但這只是「延遲」部分且具有相同問題。
雖然承諾實作會遵循標準化行為,但整體 API 有所不同。在 API 中,JavaScript 的保證與 RSVP.js 類似。以下說明建立承諾的方式:
var promise = new Promise(function(resolve, reject) {
// do a thing, possibly async, then…
if (/* everything turned out fine */) {
resolve("Stuff worked!");
}
else {
reject(Error("It broke"));
}
});
承諾建構函式會採用一個引數,即具有兩個參數的回呼,會解析並拒絕。在回呼中執行某些操作 (可能為非同步),如果一切都正常運作,就呼叫解析,否則呼叫拒接。
和純舊 JavaScript 中的 throw
一樣,這是正常情況,但不一定需要用到 Error 物件。Error 物件的好處是可以擷取堆疊追蹤,讓偵錯工具更加實用。
以下說明您如何運用這項承諾:
promise.then(function(result) {
console.log(result); // "Stuff worked!"
}, function(err) {
console.log(err); // Error: "It broke"
});
then()
會使用兩個引數,一個成功案例的回呼,另一個用於失敗案例。兩者並非必要,因此您只能新增成功或失敗情況的回呼。
JavaScript 保證在 DOM 中啟動為「Futures」,並重新命名為「Promise」,最後則移至 JavaScript。將模組放在 JavaScript 而非 DOM 中,是非常實用的,因為將在 Node.js 等非瀏覽器的 JS 環境中提供這些程式碼 (是否在核心 API 中使用這類程式碼)。
雖然是 JavaScript 功能,但是 DOM 無法使用。事實上,所有具備非同步成功/失敗方法的新 DOM API 都會使用承諾。這已經在配額管理、字型載入事件、ServiceWorker、Web MIDI、串流等主題中發生。
與其他程式庫的相容性
JavaScript 承諾 API 會將使用 then()
方法的任何行為視為承諾的 (或承諾語的 thenable
中的 thenable
),因此如果您使用的程式庫會傳回 Q 承諾,這沒有問題,但新的 JavaScript 承諾可以滿足您的需求。
不過如我所說,jQuery 的「延遲」有點不實用。 幸好您可以將這些承諾轉換為標準承諾,而這應該盡快完成:
var jsPromise = Promise.resolve($.ajax('/whatever.json'))
這裡,jQuery 的 $.ajax
會傳回 Deferred。因為有 then()
方法,Promise.resolve()
可以將該方法轉換成 JavaScript 承諾。不過,有時延遲會將多個引數傳遞至回呼,例如:
var jqDeferred = $.ajax('/whatever.json');
jqDeferred.then(function(response, statusText, xhrObj) {
// ...
}, function(xhrObj, textStatus, err) {
// ...
})
然而,JS 可保證只會忽略第一個細節:
jsPromise.then(function(response) {
// ...
}, function(xhrObj) {
// ...
})
幸好這通常是您想要的,或至少可以提供您想要的內容。另請注意,jQuery 並不遵循將 Error 物件傳送至拒絕要求的慣例。
簡化複雜的非同步程式碼
現在來編寫一些程式碼假設我們想:
- 啟動旋轉圖示來表示載入中
- 擷取故事的部分 JSON,讓我們取得每個章節的標題和網址
- 新增頁面標題
- 擷取各章節
- 將故事新增至頁面
- 停止旋轉圖示
...而且也告知使用者註冊過程是否發生問題。我們也希望停止旋轉圖示,否則它會一直旋轉、感到不悅並當機到其他 UI 中。
當然,您也不應使用 JavaScript 來傳遞故事,因為 HTML 速度較快,但這種模式在處理 API 時很常見:多個資料擷取,然後在所有作業完成後再執行其他操作。
首先,我們要從網路擷取資料:
證明 XMLHttpRequest
如果可能的話,系統會更新舊 API 以使用承諾功能。XMLHttpRequest
是首選候選函式,但在平均時間中,我們來撰寫一個簡單的函式來提出 GET 要求:
function get(url) {
// Return a new promise.
return new Promise(function(resolve, reject) {
// Do the usual XHR stuff
var req = new XMLHttpRequest();
req.open('GET', url);
req.onload = function() {
// This is called even on 404 etc
// so check the status
if (req.status == 200) {
// Resolve the promise with the response text
resolve(req.response);
}
else {
// Otherwise reject with the status text
// which will hopefully be a meaningful error
reject(Error(req.statusText));
}
};
// Handle network errors
req.onerror = function() {
reject(Error("Network Error"));
};
// Make the request
req.send();
});
}
現在,我們來使用:
get('story.json').then(function(response) {
console.log("Success!", response);
}, function(error) {
console.error("Failed!", error);
})
現在,我們不用手動輸入 XMLHttpRequest
就能發出 HTTP 要求,這是非常好的做法,因為只要看到 XMLHttpRequest
不斷製造的駝峰式大小寫,我的生活就越開心。
鏈結
then()
不是故事的終點,您可以將 then
鏈結在一起來轉換值,或依序執行其他非同步動作。
轉換值
您只要傳回新值即可轉換值:
var promise = new Promise(function(resolve, reject) {
resolve(1);
});
promise.then(function(val) {
console.log(val); // 1
return val + 2;
}).then(function(val) {
console.log(val); // 3
})
舉例來說,讓我們回顧一下:
get('story.json').then(function(response) {
console.log("Success!", response);
})
回應為 JSON,但我們現在接收到純文字格式。我們可以變更 get 函式來使用 JSON responseType
,但也可以在承諾降落中解決此函式:
get('story.json').then(function(response) {
return JSON.parse(response);
}).then(function(response) {
console.log("Yey JSON!", response);
})
由於 JSON.parse()
會使用單一引數並傳回轉換後的值,因此我們可以建立捷徑:
get('story.json').then(JSON.parse).then(function(response) {
console.log("Yey JSON!", response);
})
事實上,我們能夠輕易讓 getJSON()
函式變得十分簡單:
function getJSON(url) {
return get(url).then(JSON.parse);
}
getJSON()
仍會傳回承諾,而這個項目會擷取網址,然後將回應剖析為 JSON。
將非同步動作排入佇列
您也可以鏈結 then
,依序執行非同步動作。
透過 then()
回呼傳回內容時,有點神奇的作用。如果傳回值,系統會使用該值呼叫下一個 then()
。不過,如果您傳回類似承諾的內容,下一個 then()
會等候該結果,且只有在保證和結果發生時 (成功/失敗) 才會呼叫。例如:
getJSON('story.json').then(function(story) {
return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
console.log("Got chapter 1!", chapter1);
})
我們在此向 story.json
提出非同步要求,其中提供了一組要請求的網址,然後向我們請求第一個。當保證結果確實從簡單的回呼模式中有所區別時,就會發生這種情況。
你甚至可以建立捷徑方法,以便取得章節:
var storyPromise;
function getChapter(i) {
storyPromise = storyPromise || getJSON('story.json');
return storyPromise.then(function(story) {
return getJSON(story.chapterUrls[i]);
})
}
// and using it is simple:
getChapter(0).then(function(chapter) {
console.log(chapter);
return getChapter(1);
}).then(function(chapter) {
console.log(chapter);
})
在呼叫 getChapter
之前,我們不會下載 story.json
,但下次呼叫 getChapter
時,我們會重複使用故事承諾,因此 story.json
只會擷取一次。哇,我很厲害!
處理錯誤
如先前所述,then()
會採用兩個引數,一個代表成功,一個代表失敗 (或完成與拒絕,則以「承諾」文字表示):
get('story.json').then(function(response) {
console.log("Success!", response);
}, function(error) {
console.log("Failed!", error);
})
您也可以使用 catch()
:
get('story.json').then(function(response) {
console.log("Success!", response);
}).catch(function(error) {
console.log("Failed!", error);
})
catch()
沒有特別之處,它只是 then(undefined, func)
的糖,但是更易讀。請注意,上述兩個程式碼範例的運作方式不同,後者等同於:
get('story.json').then(function(response) {
console.log("Success!", response);
}).then(undefined, function(error) {
console.log("Failed!", error);
})
差別在於極小,但非常實用。Promise 拒絕會跳到下一個 then()
,並使用拒絕回呼 (或 catch()
,因為對等的回呼方法)。使用 then(func1, func2)
時,系統會呼叫 func1
或 func2
,絕不會兩者都呼叫。但使用 then(func1).catch(func2)
時,如果 func1
拒絕,系統就會呼叫兩個選項,因為在鏈結中是獨立步驟。請按照下列步驟操作:
asyncThing1().then(function() {
return asyncThing2();
}).then(function() {
return asyncThing3();
}).catch(function(err) {
return asyncRecovery1();
}).then(function() {
return asyncThing4();
}, function(err) {
return asyncRecovery2();
}).catch(function(err) {
console.log("Don't worry about it");
}).then(function() {
console.log("All done!");
})
上述流程與一般的 JavaScript try/catch 非常類似,「試用」內發生的錯誤會立即移至 catch()
區塊。下圖以流程圖的形式呈現 (因為我喜歡流程圖):
遵照藍線表示可提供的承諾,而拒絕則以紅色線條表示拒絕。
JavaScript 例外狀況和保證
拒絕會在承諾明確遭拒時發生,但也可能隱含在建構函式回呼中擲回錯誤時:
var jsonPromise = new Promise(function(resolve, reject) {
// JSON.parse throws an error if you feed it some
// invalid JSON, so this implicitly rejects:
resolve(JSON.parse("This ain't JSON"));
});
jsonPromise.then(function(data) {
// This never happens:
console.log("It worked!", data);
}).catch(function(err) {
// Instead, this happens:
console.log("It failed!", err);
})
這表示在入侵的建構函式回呼中執行所有承諾相關的工作,是很實用的做法,這樣可以自動擷取錯誤並遭拒。
對 then()
回呼擲回的錯誤也是如此。
get('/').then(JSON.parse).then(function() {
// This never happens, '/' is an HTML page, not JSON
// so JSON.parse throws
console.log("It worked!", data);
}).catch(function(err) {
// Instead, this happens:
console.log("It failed!", err);
})
實務中的錯誤處理方式
我們可以透過故事和章節,用擷取的方式向使用者顯示錯誤訊息:
getJSON('story.json').then(function(story) {
return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
addHtmlToPage(chapter1.html);
}).catch(function() {
addTextToPage("Failed to show chapter");
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
如果擷取 story.chapterUrls[0]
失敗 (例如 http 500 或使用者離線),系統會略過下列所有成功回呼,包括 getJSON()
中嘗試將回應剖析為 JSON 的回呼,也會略過在頁面中加入 chapter1.html 的回呼。而是改採擷取回呼。因此,如果先前的任何操作失敗,系統就會在頁面中加入「無法顯示章節」。
與 JavaScript 的 try/catch 一樣,系統會抓到錯誤,後續的程式碼也會繼續出現,因此旋轉圖示永遠都會隱藏,這是我們想要的。上述內容會成為以下版本的非阻斷非同步版本:
try {
var story = getJSONSync('story.json');
var chapter1 = getJSONSync(story.chapterUrls[0]);
addHtmlToPage(chapter1.html);
}
catch (e) {
addTextToPage("Failed to show chapter");
}
document.querySelector('.spinner').style.display = 'none'
您可能只想使用 catch()
進行記錄,而不從錯誤中復原。要這麼做,只要再次擲回錯誤即可。我們可以透過 getJSON()
方法執行此操作:
function getJSON(url) {
return get(url).then(JSON.parse).catch(function(err) {
console.log("getJSON failed for", url, err);
throw err;
});
}
我們已經設法擷取了一章,但必須全部加入。我們開始吧
平行處理和序列:充分運用
想採取非同步的做法並不容易。如果您已經苦惱如何擺脫困境,請嘗試撰寫程式碼,就好像同步作業一樣。在這種情況下:
try {
var story = getJSONSync('story.json');
addHtmlToPage(story.heading);
story.chapterUrls.forEach(function(chapterUrl) {
var chapter = getJSONSync(chapterUrl);
addHtmlToPage(chapter.html);
});
addTextToPage("All done");
}
catch (err) {
addTextToPage("Argh, broken: " + err.message);
}
document.querySelector('.spinner').style.display = 'none'
成功了!但會在下載時同步並鎖定瀏覽器。如要進行這項非同步作業,我們會使用 then()
來依序執行其他操作。
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
// TODO: for each url in story.chapterUrls, fetch & display
}).then(function() {
// And we're all done!
addTextToPage("All done");
}).catch(function(err) {
// Catch any error that happened along the way
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
// Always hide the spinner
document.querySelector('.spinner').style.display = 'none';
})
但該如何循環播放特定章節網址,並依序擷取內容?這項無效:
story.chapterUrls.forEach(function(chapterUrl) {
// Fetch chapter
getJSON(chapterUrl).then(function(chapter) {
// and add it to the page
addHtmlToPage(chapter.html);
});
})
forEach
並非非同步感知,因此章節會以使用者下載的順序顯示,這就像是脈搏的編寫方式。這並不是脈衝波,所以讓我們來修正。
建立序列
我們希望將 chapterUrls
陣列轉換為一系列承諾。我們可以使用 then()
來完成這項操作:
// Start off with a promise that always resolves
var sequence = Promise.resolve();
// Loop through our chapter urls
story.chapterUrls.forEach(function(chapterUrl) {
// Add these actions to the end of the sequence
sequence = sequence.then(function() {
return getJSON(chapterUrl);
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
})
這是我們第一次看到 Promise.resolve()
,它會建立承諾可解析為您提供的任何值。如果將 Promise
的例項傳遞至該例項,系統只會傳回該例項 (注意:這屬於部分實作項目尚未遵循規格的變更),如果您提供類似承諾的傳送方式 (具有 then()
方法),就會建立以相同方式執行/拒絕的正版 Promise
。如果傳入任何其他值,例如Promise.resolve('Hello')
,它會建立可透過該值完成的承諾。如果如上所述,呼叫此函式沒有值,則會傳回「未定義」。
也會產生 Promise.reject(val)
,承諾拒絕要求您提供 (或未定義) 的值。
我們可以使用 array.reduce
清理上述程式碼:
// Loop through our chapter urls
story.chapterUrls.reduce(function(sequence, chapterUrl) {
// Add these actions to the end of the sequence
return sequence.then(function() {
return getJSON(chapterUrl);
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
}, Promise.resolve())
這種做法與上一個範例相同,但不需要個別的「sequence」變數。陣列中的每個項目都會呼叫減少回呼。「sequence」是第一次出現 Promise.resolve()
,但對其餘呼叫的「sequence」值會是先前呼叫傳回的內容。array.reduce
非常適用於將陣列總結為單一值的情況,在這個案例中為承諾使用值。
總結:
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
return story.chapterUrls.reduce(function(sequence, chapterUrl) {
// Once the last chapter's promise is done…
return sequence.then(function() {
// …fetch the next chapter
return getJSON(chapterUrl);
}).then(function(chapter) {
// and add it to the page
addHtmlToPage(chapter.html);
});
}, Promise.resolve());
}).then(function() {
// And we're all done!
addTextToPage("All done");
}).catch(function(err) {
// Catch any error that happened along the way
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
// Always hide the spinner
document.querySelector('.spinner').style.display = 'none';
})
這就是同步版本的同步版本但我們做得更好目前的頁面下載作業如下:
瀏覽器通常很擅長同時下載多個內容,因此會逐一下載章節,導致效能降低。我們要做的是同時下載它們,然後在一切抵達時才進行處理。 幸好有 API 可以:
Promise.all(arrayOfPromises).then(function(arrayOfResults) {
//...
})
Promise.all
會擷取承諾的陣列,並承諾只要所有承諾項目都順利完成,就能履行保證。您取得結果陣列時,會依照傳遞的承諾順序,按照相同的順序取得結果。
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
// Take an array of promises and wait on them all
return Promise.all(
// Map our array of chapter urls to
// an array of chapter json promises
story.chapterUrls.map(getJSON)
);
}).then(function(chapters) {
// Now we have the chapters jsons in order! Loop through…
chapters.forEach(function(chapter) {
// …and add to the page
addHtmlToPage(chapter.html);
});
addTextToPage("All done");
}).catch(function(err) {
// catch any error that happened so far
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
視連線狀況而定,與逐一載入訊息相比,前者載入的速度可能快一秒,所需程式碼少於我們第一次嘗試載入。章節可以以任何順序下載,但會以正確順序顯示。
不過,我們依然可以改善使用者感知的成效。第一章到達時 我們就應該在頁面中新增該章節這樣一來,使用者就能在其他章節到達前開始閱讀。當第三章到達時,我們就不會將它加入頁面,因為使用者可能不知道自己缺少第 2 章。第二章到達時 我們可以加入二、第三章等等
為此,我們會同時擷取所有章節的 JSON,然後建立序列來將其新增至文件中:
getJSON('story.json')
.then(function(story) {
addHtmlToPage(story.heading);
// Map our array of chapter urls to
// an array of chapter json promises.
// This makes sure they all download in parallel.
return story.chapterUrls.map(getJSON)
.reduce(function(sequence, chapterPromise) {
// Use reduce to chain the promises together,
// adding content to the page for each chapter
return sequence
.then(function() {
// Wait for everything in the sequence so far,
// then wait for this chapter to arrive.
return chapterPromise;
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
}, Promise.resolve());
}).then(function() {
addTextToPage("All done");
}).catch(function(err) {
// catch any error that happened along the way
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
結果顯示,兩人都大功告成!提供所有內容所需的時間相同,但使用者很快就能取得第一批內容。
在這種簡易範例中,所有章節都會在同一時間抵達,但一次顯示一個章節會加大,增加更多的章節。
使用 Node.js 樣式回呼或事件執行上述作業時,僅會圍繞兩倍程式碼,但更重要的在於其不易遵循。不過,這並不是承諾的最終階段,與其他 ES6 功能結合使用會更加容易。
額外獎勵:擴充功能
我原先撰寫這篇文章後,使用 Promise 的功能正大幅增加。自 Chrome 55 版起,非同步函式允許以保證為基準的程式碼編寫與同步相同,但不會封鎖主執行緒。詳情請參閱my async functions article。廣泛支援在主要瀏覽器中使用 Promise 和非同步函式。詳情請參閱 MDN 的 Promise 和 async 函式參考資料。
感謝 Anne van Kesteren、Domenic Denicola、Tom Ashworth、Remy Sharp、Addy Osmani、Arthur Evans 和 Yutaka Hirano 進行校對和更正/建議。
此外,感謝 Mathias Bynens 更新文章的各個部分。