本文將會詳細的向你介紹如何在JavaScript中藉助Promise來簡化非同步程式碼流。
背景知識
JavaScript是單執行緒的,這意味著程式碼是按順序執行的。對於瀏覽器而言,JavaScript程式碼和其他任務共享一個執行緒,
不同的瀏覽器略有差異,但大體上這些和JavaScript共享執行緒的任務主要包括重繪、更新樣式、使用者互動等,
所有這些任務操作都會阻塞其他任務。
避免事件阻塞的常用方法是使用事件監聽器。我們可以為某些特定事件設定監聽器,如果事件發生的話,便立刻觸發監聽器,
你應該已經習慣使用回撥函式來解決這個問題了,例如:
1 2 3 4 5 6 7 8 |
var img1 = document.querySelector('.img-1'); img1.addEventListener('load', function() { // 圖片載入完成 }); img1.addEventListener('error', function() { // 出問題了 }); |
上面的程式碼中,我們新增了兩個監聽器,請求圖片,回撥函式只在事件發生的時候才會被觸發。但是通過事件機制還存在幾個問題:
- 事件在繫結之前就發生了怎麼辦?
- 在新增監聽器之前,圖片載入發生了錯誤怎麼辦?
僅僅是一張圖片就存在這麼多問題,那麼如果有一堆圖片要處理,又該怎麼辦?下面我們就談談Promise,一個越來越流行的非同步解決方案。
Promise
JavaScript的一大特點就是會涉及到大量的非同步程式碼。同步程式碼通常易於理解和除錯,而非同步程式碼則具有更好的效能和靈活性。
目前Promise正逐漸稱為JavaScript世界的一個重要組成部分,並且很多新的API也都基於Promise進行了實現。
目前已經有一些原生API使用了Promise,包括:
- Battery API
- Fetch API
- ServiceWorker API
什麼是Promise
那麼到底什麼是Promise呢?Promise
是ES6規範新增的物件,它可以用於延遲計算和非同步計算。
一個Promise
物件代表著一個還未完成,但預期會完成的操作。需要記住:
- 一個Promise要麼成功要麼失敗,並且狀態不可變
- 可以根據Promise的結果設定特定的回撥函式
Promise的狀態
一個Promise的狀態可以是:
- 等待 pending – Promise的初始化狀態,等待結果
- 完成 fullfilled – 該Promise對應的非同步操作成功完成了
- 失敗 rejected – 該Promise對應的非同步操作失敗了
- 結束 settled – 任務完成或失敗了
基本使用
new Promise()
構造器應該只被用於傳統的非同步任務上,例如setTimeout
或XMLHttpRequest
。
通過new
關鍵字建立一個新的Promise
,它接收一個回撥函式作為引數,該回撥函式又包括了兩個特定的回撥函式,
分別被命名為resolve
和reject
,成功後呼叫resolve
,失敗則呼叫reject
。
根據不同的任務,由開發者來決定resolve
和reject
在函式體內的位置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
let p = new Promise(function(resolve, reject) { // 執行非同步任務 if(/* good condition */) { resolve('Success'); } else { // 傳遞Error物件的好處是可以包含呼叫堆疊,便於除錯 reject(Error('Failure')); } }); p.then(function(result) { // do something with the reuslt foo(result); }, function(err){ console.error(err); }); |
使用Promise則非常的簡單,可以呼叫Promise
物件的then()
方法來處理非同步計算的結果。then
接收兩個回撥函式,
分別是成功的回撥函式和失敗時的回撥函式,這兩個引數都是可選的。
Promise的使用有兩點需要記住的:
then()
方法可以鏈式呼叫catch()
方法可以作為錯誤處理語句的語法糖,相當於then(undefined, function(error) { ... });
在具體講解這兩點之前,我們先來看一個例子。下面這個例子用於將XMLHttpRequest
轉換為一個基於Promise的介面。
我們以GET請求為例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
function get(url) { // 返回一個新的 Promise return new Promise(function(resolve, reject) { // 經典 XHR 操作 var req = new XMLHttpRequest(); req.open('GET', url); req.onload = function() { // 當發生 404 等狀況的時候呼叫此函式 // 所以先檢查狀態碼 if (req.status == 200) { // 以響應文字為結果,完成此 Promise resolve(req.response); } else { // 否則就以狀態碼為結果否定掉此 Promise // (提供一個有意義的 Error 物件) reject(Error(req.statusText)); } }; // 網路異常的處理方法 req.onerror = function() { reject(Error("Network Error")); }; // 發出請求 req.send(); }); } |
我們現在可以這麼呼叫它:
1 2 3 4 5 |
get('story.json').then(function(response) { console.log("Success!", response); }, function(error) { console.error("Failed!", error); }); |
現在我們發起XHR請求便變得簡單直觀的多了。story.json
檔案的內容如下:
1 2 3 4 5 6 7 8 9 10 |
{ "heading": "A story about something", "chapterUrls": [ "chapter-1.json", "chapter-2.json", "chapter-3.json", "chapter-4.json", "chapter-5.json" ] } |
Promise.resolve
有時你無需在promise內完成一個非同步任務——如果一個非同步動作被執行是可能的話,然而,返回一個Promise是將是最合適的,
因此你可以總是期望從給定函式中產生的promise。在這種情況下,你可以簡單的呼叫Promise.resolve()
或者Promise.reject()
,
而無需new
關鍵字。例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
var userCache = {}; function getUserDetail(username) { // 兩種情況下,要麼快取要麼不快取,都將返回一個promise if (userCache[username]) { // 不使用new關鍵字返回一個promise return Promise.resolve(userCache[username]); } // 使用fetch API獲取資訊 // fetch返回一個promise return fetch('user/' + username + '.json') .then(result => { userCache[username] = result; return result; }) .catch(() => { throw new Error('Could not find user: ' + username); }); } |
由於返回的是一個Promise,你可以在返回值上使用then
和catch
方法。
可以將Promise.resolve
看作是new Promise()
的快捷方式。
鏈式呼叫
上面我們說過then()
接收兩個引數,分別對應成功和失敗時的回撥函式。我們還可以將多個then
方法串聯起來,
用於修改結果或執行更多的非同步操作。
你可以對結果進行修改,然後返回一個新的值,例如:
1 2 3 4 5 6 7 8 9 10 11 12 |
new Promise(function(resolve, reject) { // A mock async action using setTimeout setTimeout(function() { resolve(10); }, 3000); }) .then(num => { console.log('first then: ', num); return num * 2; }) .then(num => { console.log('second then: ', num); return num * 2; }) .then(num => { console.log('last then: ', num);}); // From the console: // first then: 10 // second then: 20 // last then: 40 |
每個then
接收前一個then
的返回值的結果。
回到之前的get
函式,我們可以修改返回值的型別,將結果進行一定的轉換:
1 2 3 4 5 |
get('story.json').then(function(response) { return JSON.parse(response); }).then(function(response) { console.log("Yey JSON!", response); }); |
為了讓程式碼變得更簡單,可以再次進行改進:
- 因為
JSON.parse
只接收一個引數,並返回轉換後的結果,我們可以直接使用then(JSON.parse)
then
中的回撥函式,我們可以直接使用ES6的胖箭頭函式,這樣可以讓程式碼更直觀
1 |
get('story.json').then(JSON.parse).then(response => console.log("JSON data: ", response); |
由於這段程式碼會被重複呼叫,我們可以定義一個新的getJSON
函式:
1 2 3 |
function getJSON(url) { return get(url).then(JSON.parse); // 返回一個獲取JSON並加以解析的Promise } |
對於串聯起來的then()
方法而言:如果你返回了一個值,那麼它就會被傳給下一個then()
的回撥。
如果你返回一個“類Promise”物件,則下一個then()
就會等待這個Promise明確結束(成功/失敗)才會執行。
1 2 3 |
getJSON('story.json') .then(story => getJSON(story.chapterUrls[0])) .then(chapter => console.log("Got chapter 1!, " chapter)); |
在上面的程式碼中,我們首先發起對story.json
的非同步請求,它會返回給我們一個URL列表,然後我們請求其中的第一個,。
錯誤處理
前面我們已經知道,then
接收兩個引數,一個處理成功時的回撥函式,一個處理失敗時的回撥函式。
1 2 3 4 5 |
get('story.json').then(function(response) { console.log("Success!", response); }, function(error) { console.log("Failed!", error); }); |
你還可以使用catch
來進行錯誤處理,實際上,它不過是then(undefined, func)
的語法糖而已。這樣能夠讓程式碼更直觀:
1 2 3 |
get('story.json') .then(response => console.log('Success!', response)) .catch(error => console.error('Failed!', error)); |
並行和序列
非同步意味著你不用等待前一件事情做完就可以做後一件事。現在,我們想要遍歷所有章節的URL並且依次請求,應該怎麼辦?
使用傳統的方法,你可能會想到array.forEach
:
1 2 3 4 5 6 |
story.chapterUrls.forEach(chapterUrl => { // getJSON是非同步操作 getJSON(chapterUrl).then(chapter => { addHtmlToPage(chapter.html) }); }); |
但是這麼做並不可行,因為forEach
並不支援非同步操作!
Promise序列
1 2 3 4 5 6 7 8 |
// 遍歷所有章節的 url story.chapterUrls.reduce(function(sequence, chapterUrl) { // 從 sequence 開始把操作接龍起來 return sequence.then(() => getJSON(chapterUrl)) .then(chapter => { addHtmlToPage(chapter.html) ; }); }, Promise.resolve()); |
上面的程式碼中使用了Promise.resolve()
,它會依據你傳入的任何值返回一個Promise。
如果你傳給它一個類Promise物件(帶有then
方法),它會生成一個帶有同樣肯定/否定回撥的Promsie。
如果你傳給它任何別的值,如Promise.resolve('hello')
,它會建立一個以該值為完成結果的Promise
,
如過不傳入任何值,則以undefined
為完成結果。
reduce
回撥會一次應用在每一個陣列元素上,第一輪的sequence
是Promise.resolve()
,
之後的呼叫裡sequence
就是上次函式執行的結果。reduce()
方法非常適合用於把一個值歸併處理為一個值。
Array.prototype.reduce(callback, [initialValue])
方法接收一個函式作為累加器,陣列中的每個值(從左到右)開始合併,
最終為一個值。引數二作為第一次呼叫callback的第一個引數。此外,callback包括四個引數:
- previousValue – 上一次呼叫回撥返回的值,或者是提供的初始值(initialValue)
- currentValue – 陣列中當前被處理的元素
- index – 當前元素在陣列中的索引
- array – 呼叫reduce的陣列
彙總前面的程式碼為:
1 2 3 4 5 6 7 8 9 10 11 |
getJSON('story.json') .then(story => { addHtmlToPage(story.heading); return story.chapterUrls.reduce((sequence, chapterUrl) => { return sequence.then(() => getJSON(chapterUrl)) .then(chapter => addHtmlToPage(chapter.html)); }, Promise.resolve()); }) .then(() => addTextToPage('All done')) .catch(err => addTextToPage('Argh, broken: ' + err.message)) .then(() => document.querySelector('.spinner').style.display = 'none'); |
輔助方法定義如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
var storyDiv = document.querySelector('.story'); function addHtmlToPage (html) { var div = document.createElement('div'); div.innerHTML = html; storyDiv.appendChild(div); } function addTextToPage (text) { var p = document.createElement('p'); p.textContent = text; storyDiv.appendChild(p); } |
Promise.all
瀏覽器很擅長同時載入多個檔案,上面的方法屬於一個接一個下載章節,這先得非常的低效。我們希望同時下載所有章節,
全部完成後一次搞定,正好就有這麼個API:
1 |
Promise.all(arrayOfPromise).then(arrayOfResults => {} ); |
Promise.all
接收一個Promise陣列作為引數,建立一個當所有Promise都完成之後就完成的Promise,它的完成結果是一個陣列,
包含了所有先前傳入的那些Promise的完成結果,順序和將它們傳入的陣列順序一致。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
getJSON('story.json').then(story => { addHtmlToPage(story.heading); // 接收一個Promise陣列並等待他們全部結束 // 把章節URL陣列轉換成對應的Promise陣列 return Promise.all(story.chapterUrls.map(getJSON)); }).then(chapters => { // 現在我們有了順序的章節JSON,遍歷它們 // 並新增到頁面中 chapters.forEach(chapter => addHtmlToPage(chapter.html)); addTextToPage('All done'); }) // 捕獲過程中的任何錯誤 .catch(err => addTextToPage('Argh, broken: ' + err.message)) .then(() => document.querySelector('.spinner').style.display = 'none'); |
根據連線狀況,改進的程式碼會比順序載入方式提速數秒,甚至程式碼行數也更少。章節載入完成的順序不確定,
但它們顯示在頁面上的順序準確無誤。
但仍然有改進空間:第一章內容載入完成後,我們向讓它立即填進頁面,這樣使用者可以在其他載入任務尚未完成之前就開始閱讀。
當第三章到達的時候我們不動聲色,第二章也到達之後我們再把第二章和第三章內容填入頁面,以此類推。
為了達到這個效果,我們同時請求所有的章節內容,然後建立一個序列依次將其填入頁面:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
getJSON('story.json') .then(story => { addHtmlToPage(story.heading); // 把章節 URL 陣列轉換成對應的 Promise 陣列 // 這樣就可以並行載入它們 return story.chapterUrls.map(getJSON) .reduce((sequence, chapterPromise) => { // 使用 reduce 把這些 Promise 接龍 // 以及將章節內容新增到頁面 return sequence // 等待當前 sequence 中所有章節和本章節的資料到達 .then(() => chapterPromise) .then(chapter => { addHtmlToPage(chapter.html) }); }, Promise.resolve()); }) .then(() => { addTextToPage("All done") }) // 捕獲過程中的任何錯誤 .catch(err => { addTextToPage("Argh, broken: " + err.message) }) .then(() => { document.querySelector('.spinner').style.display = 'none' }); |