偶然在 Google Web Fundamentals 看到了這篇文章 JavaScript Promises: an Introduction, 打算試著翻成繁中來試著累積些知識 (部分前言鋪陳沒有進行翻譯). 翻完後發 email 與原作者聯繫才知道有簡中的版本, 並且 Google 有提供實驗性的翻譯工具叫做 GitLocalize, 讓全球的開發者可以為發表的文獻提供並參與翻譯. 可以參考此文章進行了解如何參與.
開發人員準備好迎接在網絡發展史上的關鍵時刻
Promises have arrived natively in JavaScript!
小題大作 ?
JavaScript 是單線程, 代表著腳本運行是相繼運行的. 瀏覽器中 JavaScript 共享線程, 通常渲染、更新樣式、處理用戶操作處於同個佇列中. 其中的事件延遲了其他的事件. 以人類來做比喻的話人類就像是多線程. 可以多個手指打字, 也能夠開車同時與別人對話. 唯一會中斷其他行為(阻塞)的只有打噴嚏(sneezing), 所有當前的事情都會在打噴嚏時被暫停. 這很煩人. 特別是當你開車, 試圖想要談話. 同樣地你不會想編寫類似有打噴嚏(sneezing)效果的代碼.
你可能已經使用了事件與回調來解決過類似問題:
var img1 = document.querySelector('.img-1');
img1.addEventListener('load', function() {
// 圖片載入完
});
img1.addEventListener('error', function() {
// 載入 error
});
在圖像 img1, 添加幾個監聽器, JavaScript 執行到最後一行就結束了. 直到其中一個監聽器被調用. 不幸的是, 在上面的例子中, 可能事件發生在我們開始監聽之前, 所以我們需要使用圖像的“complete”屬性來解決這個問題:
var img1 = document.querySelector('.img-1');
function loaded() {
// 圖片載入完
}
if (img1.complete) {
loaded();
}
else {
img1.addEventListener('load', loaded);
}
img1.addEventListener('error', function() {
// 載入 error
});
在進入到錯誤監聽前不能捕捉錯誤的圖像; 不幸的是, DOM並沒有提供好的方法. 此外, 這是載入一個圖像的範例, 如果我們想知道一組圖像何時載入會讓事情變得更加複雜.
Events 不會總是最佳解
Events 對於可以在同一個對像上進行多次發生的事情非常有用 - keyup、touchstart等. 在這些事件之前, 您並不關心在監聽器之前發生了什麼. 但是, 當談到異步成功/失敗時, 理想情況下你需要這樣的東西:
img1.callThisIfLoadedOrWhenLoaded(function() {
// 載入完成...
}).orIfFailedCallThis(function() {
// 載入失敗...
});
// and…
whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
// 全部載入完...
}).orIfSomeFailedCallThis(function() {
// 一個或是多個載入失敗...
});
上面就是以 Promise 的作法只是命名採取白話. 如果HTML圖像元素具有返回 Promise 的“ready”方法, 我們可以這樣做:
img1.ready().then(function() {
// 載入完成...
}, function() {
// 載入失敗...
});
// and…
Promise.all([img1.ready(), img2.ready()]).then(function() {
// 全部載入完...
}, function() {
// 一個或是多個載入失敗...
});
在基本中 Promise 有點類似 Event Listener, 除了:
- promise 只能成功或失敗一次. 它不能成功或失敗兩次, 也不能從成功切換到失敗, 反之亦然.
- 如果 promise 成功或失敗, 並且您稍後添加成功/失敗回調, 即使事件發生得較早, 也將呼叫正確的回調.
這對於異步成功/失敗非常有用, 因為你對何時被調用並不感興趣, 你只對有沒有被調用更感興趣.
Promise 術語
- fulfilled - 與 promise 相關的行動成功
- rejected - 與 promise 有關的行動失敗了
- pending - 尚未履行或拒絕
- settled - 已履行或拒絕
此規格使用術語“thenable”來描述一個類似 promise 的對象,因為它具有一個 then()
方法。
Promises arrive in JavaScript!
promise 已經在很多 Library 存在好一段時間了, 如:
上述的 Libraray 與 Javascript Promise 都享有行為標準化 Promise/A+, 如果您是個 jQuery 使用者有個類似的東西稱為 Deferreds. 然而 Deferred 不符合 Promise/A+ 標準, 這使得他們有所不同且不怎麼好用, 所以要小心. jQuery 還有一個 Promise 類型但只是 Deferred 一個子集且有相同的問題.
雖然 Promise 實作遵循標準化行為, 但其整體API卻不同. JavaScript Promise 在 API 中與 RSVP.js 類似. 創建 promise 的方法如下:
var promise = new Promise(function(resolve, reject) {
// 做些事情, 比如說非同步事件...
if (/* 一切如預期完成 */) {
resolve("Stuff worked!");
}
else {
reject(Error("It broke"));
}
});
promise 建構式使用一個參數, 帶有兩個參數的回調, 解析和拒絕. 在回調中執行某些操作, 也許是異步, 一切正常然後調用解析方法, 否則調用拒絕.
比較早期的寫法會使用throw
Error 對象來 reject 它, 但不是必需的. Error 對象的好處是它們容易捕獲堆棧跟踪對調試有所幫助.
以下是您如何使用該 Promise:
promise.then(function(result) {
console.log(result); // "正常運作!"
}, function(err) {
console.log(err); // 錯誤"
});
then()
接受兩個參數, 一個成功案例的回調, 另一個為失敗案例. 兩者都是可選的, 因此您只能為成功或失敗情況添加回調.
JavaScript promises 在DOM中開始為“Futures”,更名為“Promises”, 最終轉入JavaScript. 使用JavaScript而不是DOM是非常好的, 因為它們將在非瀏覽器JS上下文(如Node.js)中可用(無論他們在核心API中使用它們是另一個問題).
雖然它們是JavaScript功能, 但DOM並不害怕使用它們. 事實上, 所有具有異步成功/失敗方法的新的DOM API都將使用 promises. 這已經發生在Quota Management, Font Load Events, ServiceWorker, Web MIDI, Streams等.
瀏覽器 support & polyfill
從Chrome 32起, Opera 19, Firefox 29, Safari 8和Microsoft Edge, 默認情況下啟用.
為了使缺少 promises 的瀏覽器達到規範合規性, 或者向其他瀏覽器和Node.js添加 promises, 請查看 polyfill(2k gzipped).
與其他 Libraries 兼容
JavaScript promises API 將使用 then()
像 promise 一樣處理任何事情, 所以如果你使用的是返回 Q promise 的 Library 就會很容易的兼容 JavaScript promises.
雖然, 正如我所提到的, jQuery的Deferreds有點...沒有幫助. 謝天謝地, 你可以把它們放在標準的 promises 上, 這是值得做的:
var jsPromise = Promise.resolve($.ajax('/whatever.json'))
jQuery 的 $ .ajax 會返回 Deferred. 由於它有一個 then()
方法,Promise.resolve()
可以將其轉換為 JavaScript promise. 但是有時 Deferreds 會將多個參數傳遞給回調, 例如:
var jqDeferred = $.ajax('/whatever.json');
jqDeferred.then(function(response, statusText, xhrObj) {
// ...
}, function(xhrObj, textStatus, err) {
// ...
})
而 JS promise 只會留有一個參數
jsPromise.then(function(response) {
// ...
}, function(xhrObj) {
// ...
})
幸運的是這通常是你想要的, 或至少讓你訪問你想要的. 另外請注意, jQuery 不遵循將當被拒絕時傳遞 Error 對象.
簡化複雜的非同步代碼
編寫代碼如下需求:
- 旋轉 icon (.spinner) 代表加載數據
- 獲取一段故事內含標題及每個章節 URL
- 添加標題到 Page
- 獲取每一個章節
- 將故事添加到 Page
- 停止旋轉 icon (.spinner)
首先要編寫處理的是利用網路提取數據:
Promisifying XMLHttpRequest
舊 API 將被更新以使用 promises, 盡量以向後兼容的方式. XMLHttpRequest 是一個主要的候選者, 讓我們寫一個簡單的函數來做出GET請求:
function get(url) {
// Return a new promise.
return new Promise(function(resolve, reject) {
// 生成 XHR 物件
var req = new XMLHttpRequest();
req.open('GET', url);
req.onload = function() {
// 確認請求狀態
if (req.status == 200) {
// 調用解析把回應丟進參數
resolve(req.response);
}
else {
// 調用拒絕將做成 Error 對象丟進參數
reject(Error(req.statusText));
}
};
// Handle network errors
req.onerror = function() {
reject(Error("Network Error"));
};
// 發送請求
req.send();
});
}
使用 get()
如下:
get('story.json').then(function(response) {
console.log("Success!", response);
}, function(error) {
console.error("Failed!", error);
})
Chaining (鏈 - 很彆扭的翻譯)
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
})
如果以上述的 story 當作範例, 由於回應拿回來的是 string 文本, 能過透過 JSON.parse() 轉換成 JSON 物件, 再繼續執行操作.
get('story.json').then(function(response) {
return JSON.parse(response);
}).then(function(response) {
console.log("Yey JSON!", response);
})
// 所以可以抽成 getJSON function
// 取代原本要轉 JSON 物件的麻煩
function getJSON(url) {
return get(url).then(JSON.parse);
}
Queuing asynchronous actions 非同步操作柱列
你可以透過 then()
來鏈接非同步動作達到依次運行
當你從一個 then()
回調返回一些東西, 這有點神奇. 如果返回 values, 如果再次調用 then()
則會獲得該 values. 但是如果你返回 promises, 那麼下一個 then()
, 只有當這個 promise解決(成功/失敗)時才會被調用. 例如:
getJSON('story.json').then(function(story) {
return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
console.log("拿取章節 1!", chapter1);
})
這裡向 story.json 發出一個異步請求, 利用了 response 提供的 url 再次發出章節請求.
你甚至可以抽一個方法來處理獲取章節請求:
var storyPromise;
function getChapter(i) {
storyPromise = storyPromise || getJSON('story.json');
return storyPromise.then(function(story) {
return getJSON(story.chapterUrls[i]);
})
}
// 呼叫 getChapter()
getChapter(0).then(function(chapter) {
console.log(chapter);
return getChapter(1);
}).then(function(chapter) {
console.log(chapter);
})
在呼叫 getChapter()
之前, 我們不會下載 story.json,但是下一次呼叫 getChapter()
時, 我們重複使用原本的 storyPromise, 所以 story.json 只被獲取一次.
錯誤處理
如前所述 then()
有兩個參數, 一個是成功的, 一個是失敗的(或者在 promise 中 fulfill 或 reject):
get('story.json').then(function(response) {
console.log("成功!", response);
}, function(error) {
console.log("失敗!", error);
})
也可以使用 catch()
:
get('story.json').then(function(response) {
console.log("成功!", response);
}).catch(function(error) {
console.log("失敗!", error);
})
catch()
並沒有什麼特別的, 它就只是個類似語法糖讓代碼更容易閱讀. 請注意上述的 2 個範例行為不一樣, 後者相當於:
get('story.json').then(function(response) {
console.log("成功!", response);
}).then(undefined, function(error) {
console.log("失敗!", error);
})
這兩者差異是很微妙但是卻很有用. Promise 被拒絕後會跳過 then()
然後呼叫 catch()
. 如果是使用 then(func1, func2)
, func1 或 func2 只有其一會被調用, 不可能同時調用. 但是如果寫成 then(func1).catch(func2)
, 當 func1 被拒絕的話兩者都會被呼叫, 因為它們是 Chain 中的單獨步驟, 示例如下:
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,在“try”中發生的錯誤立即轉到 catch()
. 藍線代表成功的路線, 紅線代表拒絕的路線.
JavaScript 例外與 promise
在構造函數中 throw 一個 Error:
var jsonPromise = new Promise(function(resolve, reject) {
// 直接 throw, 因為無法 pasre 成 JSON 物件
resolve(JSON.parse("This ain't JSON"));
});
jsonPromise.then(function(data) {
// 不會執行
console.log("It worked!", data);
}).catch(function(err) {
// 會執行
console.log("It failed!", err);
})
這意味著在 promise 構造函數回調中執行所有與 promise 相關的事情很有用, 所以錯誤被 catch()
並成為 reject.
對於 then()
回調中拋出的錯誤也是如此.
get('/').then(JSON.parse).then(function() {
// 不會走進來
// '/' 是 html page 無法 JSON.parse 會 throw
console.log("It worked!", data);
}).catch(function(err) {
// 會發生
console.log("It failed!", err);
})
實務上錯誤處理
可以來改良取得故事和取得章節的範例, 我們可以使用 catch()
向用戶顯示錯誤:
getJSON('story.json').then(function(story) {
return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
addHtmlToPage(chapter1.html);
}).catch(function() {
addTextToPage("頁面失敗");
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
如果獲取story.chapterUrls [0]失敗(例如 http 500或用戶離線), 則將跳過所有以下成功回調,其中包含嘗試以JSON解析響應的 getJSON()
中的成功回調, 並跳過將chapter1.html添加到頁面的調用, 而是呼叫 catch()
. 因此如果任何先前的操作失敗, “頁面失敗”將被添加到頁面.
像JavaScript的try / catch一樣錯誤被捕獲, 隨後的代碼繼續, 所以 .spinner
(旋轉 icon)總是被隱藏, 這就是我們想要的.
如果要變成同步的版本如下:
try {
var story = getJSONSync('story.json');
var chapter1 = getJSONSync(story.chapterUrls[0]);
addHtmlToPage(chapter1.html);
}
catch (e) {
addTextToPage("頁面失敗");
}
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;
});
}
並行(Parallelism)和排序(Sequencing):兩者兼得
思考非同步事件是件不容易的事情. 如果您正在努力脫穎而出, 請嘗試編寫代碼, 在這種同步事件的情況下:
try {
var story = getJSONSync('story.json');
addHtmlToPage(story.heading);
story.chapterUrls.forEach(function(chapterUrl) {
var chapter = getJSONSync(chapterUrl);
addHtmlToPage(chapter.html);
});
addTextToPage("全部完成");
}
catch (err) {
addTextToPage("阿, 錯誤: " + 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("全部完成");
}).catch(function(err) {
// Catch any error that happened along the way
addTextToPage("阿, 錯誤: " + err.message);
}).then(function() {
// 永遠最後隱藏旋轉 Icon
document.querySelector('.spinner').style.display = 'none';
})
但是我們如何循環遍歷章節 urls 並按順序(in order)獲取它們? 以下的版本是錯誤:
story.chapterUrls.forEach(function(chapterUrl) {
// Fetch chapter
getJSON(chapterUrl).then(function(chapter) {
// and add it to the page
addHtmlToPage(chapter.html);
});
})
forEach
不是非同步感知(async-aware), 所以章節下載將會以他們下載速度的順序出現. 所以讓我們來解決.
創建序列
我們想將章節下載轉成有順序的 promise, 這樣就能使用 then()
// 創建一個 promise 是已經 resolve 了
var sequence = Promise.resolve();
// 章節 Urls 迴圈
story.chapterUrls.forEach(function(chapterUrl) {
// 把 then() 事件加入至 sequence 結尾
sequence = sequence.then(function() {
return getJSON(chapterUrl);
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
})
這是我們第一次看到 Promise.resolve()
, 它創建了一個可以解決你給它的任何值的 promise. 如果你傳遞一個Promise的實例,將簡單地返回它(注意:這是一些修改, 一些實現還沒有遵循)
如果你傳遞一些 promise-like 的東西(有一個then()
方法)去創建一個真正的Promise會以同樣的方式實現/拒絕.
如果您傳遞任何其他 values (例如Promise.resolve('Hello')
)來創建一個滿足該 values 的 promise.
如果你呼叫未回傳任何值那接續then()
得到的將是 "undefined".
Promise.reject(val)
創造了一個拒絕狀態的 promise, 接續then()
得到的 values (或 undefined).
我們可以使用 array.reduce 來整理上面的代碼:
// 章節 Urls 迴圈
story.chapterUrls.reduce(function(sequence, chapterUrl) {
// 將事件一直累加
return sequence.then(function() {
return getJSON(chapterUrl);
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
}, Promise.resolve())
這與前面的例子是一樣的, 但不需要單獨的“sequence” 變數. 我們為數組中的每個項目調用了reduce回調. “sequence”是Promise.resolve()
, 但是對於其餘的調用“sequence”是累加之前調用返回的. array.reduce對於將數組轉換為單個值非常有用, 在這種情況下轉換成一個 promise.
所以全部代碼整在以下:
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
return story.chapterUrls.reduce(function(sequence, chapterUrl) {
// sequence 累加同個 promise
return sequence.then(function() {
// …獲取下個章節
return getJSON(chapterUrl);
}).then(function(chapter) {
// 加入到頁面上
addHtmlToPage(chapter.html);
});
}, Promise.resolve());
}).then(function() {
// And we're all done!
addTextToPage("全部完成");
}).catch(function(err) {
// Catch any error that happened along the way
addTextToPage("阿, 錯誤: " + err.message);
}).then(function() {
// 隱藏旋轉 Icon
document.querySelector('.spinner').style.display = 'none';
})
完成順序的非同步版本效果如下
瀏覽器可以一次下載多個東西, 因此我們通過一個接一個地下載章節來降低性能. 如果我們想要做的是同時下載它們, 然後在章節取得時同時處理它們是有一個API:
Promise.all(arrayOfPromises).then(function(arrayOfResults) {
//...
})
Promise.all()
採取一系列的 promise, 並創造一個 promise, 當所有的成功完成後實現. 您得到一系列結果與您傳遞的 promise 的順序相同.
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
// 回傳一個 promise
return Promise.all(
// 請求 promise 的陣列
story.chapterUrls.map(getJSON)
);
}).then(function(chapters) {
// 取得了所有章節的回應
chapters.forEach(function(chapter) {
// 加入至頁面
addHtmlToPage(chapter.html);
});
addTextToPage("全部完成");
}).catch(function(err) {
// catch any error that happened so far
addTextToPage("阿, 錯誤: " + err.message);
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
這可能比逐個加載快幾秒, 而且比我們第一次嘗試的代碼要少. 這些章節可以按任何順序下載, 但它們以傳入的順序顯示在頁面上.
然而我們仍然可以改善使用者體驗. 當第一章到達時, 我們應該將其添加到頁面. 這讓用戶在其余章節到達之前開始閱讀. 當第三章到達時, 我們不會將其添加到頁面, 因為用戶可能沒有意識到第二章丟失. 當第二章到達時, 我們可以添加第二章和第三章等.
為此我們在同一時間為所有章節提取JSON, 然後創建一個序列以將其添加到文檔中:
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
// 章節 Promises map Array > 併行下載
return story.chapterUrls.map(getJSON)
.reduce(function(sequence, chapterPromise) {
// 將 promise 用 reduce 方式累加 action
return sequence.then(function() {
// 當前一個 sequence 抵達時才會繼續處理
return chapterPromise;
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
}, Promise.resolve());
}).then(function() {
addTextToPage("全部完成");
}).catch(function(err) {
// catch any error that happened along the way
addTextToPage("阿, 錯誤: " + err.message);
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
將併行任務與順序任務搭在一起時, 給出最好的體驗.
在這個的例子中, 所有這些章節都在同一時間到達, 但是每次渲染一個章節的好處就是能夠確保順序.
但更重要的是沒那麼容易遵循. 然而這並不是 Promise 的結尾, 當與其他ES6功能相結合時, 它們變得更加容易.