譯 - JavaScript Promises

偶然在 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 對象.

簡化複雜的非同步代碼

編寫代碼如下需求:

  1. 旋轉 icon (.spinner) 代表加載數據
  2. 獲取一段故事內含標題及每個章節 URL
  3. 添加標題到 Page
  4. 獲取每一個章節
  5. 將故事添加到 Page
  6. 停止旋轉 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功能相結合時, 它們變得更加容易.

Bonus round: promises and generators (此章節暫不翻譯)