2022 年 2 月 6 日

非同步/等待

有一種特殊的語法可以更輕鬆地使用 Promise,稱為「非同步/等待」。它出乎意料地容易理解和使用。

非同步函式

讓我們從 async 關鍵字開始。它可以放在函式前面,如下所示

async function f() {
  return 1;
}

函數前的「async」字詞只有一個簡單的意思:函數總是會傳回一個 Promise。其他值會自動包裝在已解決的 Promise 中。

例如,這個函數傳回一個已解決的 Promise,其結果為 1;我們來測試看看

async function f() {
  return 1;
}

f().then(alert); // 1

…我們可以明確傳回一個 Promise,這會是一樣的

async function f() {
  return Promise.resolve(1);
}

f().then(alert); // 1

所以,async 確保函數傳回一個 Promise,並將非 Promise 包裝在其中。夠簡單吧?但不僅如此。還有另一個關鍵字 await,它只會在 async 函數內運作,而且非常棒。

Await

語法

// works only inside async functions
let value = await promise;

關鍵字 await 會讓 JavaScript 等待 Promise 解決並傳回其結果。

以下是 Promise 在 1 秒後解決的範例

async function f() {

  let promise = new Promise((resolve, reject) => {
    setTimeout(() => resolve("done!"), 1000)
  });

  let result = await promise; // wait until the promise resolves (*)

  alert(result); // "done!"
}

f();

函數執行會在 (*) 行暫停,並在 Promise 解決時繼續執行,而 result 會變成其結果。因此,上述程式碼會在 1 秒後顯示「完成!」。

我們強調:await 會在 Promise 解決前暫停函數執行,然後使用 Promise 結果繼續執行。這不會耗用任何 CPU 資源,因為 JavaScript 引擎可以在這段時間執行其他工作:執行其他指令碼、處理事件等等。

這只不過是取得 Promise 結果比 promise.then 更簡潔的語法。而且,它更容易閱讀和撰寫。

無法在一般函數中使用 await

如果我們嘗試在非非同步函數中使用 await,就會發生語法錯誤

function f() {
  let promise = Promise.resolve(1);
  let result = await promise; // Syntax error
}

如果我們忘記在函數前加上 async,可能會收到此錯誤。如前所述,await 僅在 async 函數內運作。

我們從 Promise 串接 章節中取出 showAvatar() 範例,並使用 async/await 重新撰寫

  1. 我們需要將 .then 呼叫替換為 await
  2. 我們也應該讓函數成為 async 函數,才能讓它們運作。
async function showAvatar() {

  // read our JSON
  let response = await fetch('/article/promise-chaining/user.json');
  let user = await response.json();

  // read github user
  let githubResponse = await fetch(`https://api.github.com/users/${user.name}`);
  let githubUser = await githubResponse.json();

  // show the avatar
  let img = document.createElement('img');
  img.src = githubUser.avatar_url;
  img.className = "promise-avatar-example";
  document.body.append(img);

  // wait 3 seconds
  await new Promise((resolve, reject) => setTimeout(resolve, 3000));

  img.remove();

  return githubUser;
}

showAvatar();

很簡潔且容易閱讀,對吧?比之前好多了。

現代瀏覽器允許在模組中使用頂層 await

在現代瀏覽器中,當我們在模組內時,頂層的 await 可以正常運作。我們將在 模組簡介 一文中介紹模組。

例如

// we assume this code runs at top level, inside a module
let response = await fetch('/article/promise-chaining/user.json');
let user = await response.json();

console.log(user);

如果我們不使用模組,或必須支援 較舊的瀏覽器,有一個通用的方法:包裝成匿名非同步函數。

像這樣

(async () => {
  let response = await fetch('/article/promise-chaining/user.json');
  let user = await response.json();
  ...
})();
await 接受「thenable」

promise.then 類似,await 允許我們使用 thenable 物件(那些具有可呼叫 then 方法的物件)。其概念是第三方物件可能不是 Promise,但與 Promise 相容:如果它支援 .then,就足以使用 await

以下是一個示範性的 Thenable 類別;其下的 await 接受其執行個體

class Thenable {
  constructor(num) {
    this.num = num;
  }
  then(resolve, reject) {
    alert(resolve);
    // resolve with this.num*2 after 1000ms
    setTimeout(() => resolve(this.num * 2), 1000); // (*)
  }
}

async function f() {
  // waits for 1 second, then result becomes 2
  let result = await new Thenable(1);
  alert(result);
}

f();

如果 await 取得一個非 Promise 物件,且該物件具有 .then,它會呼叫該方法,提供內建函式 resolvereject 作為引數(就像它對一般的 Promise 執行器所做的那樣)。然後 await 會等到其中一個函式被呼叫(在上面的範例中,發生在 (*) 行),然後繼續執行結果。

非同步類別方法

若要宣告一個非同步類別方法,只要在前面加上 async

class Waiter {
  async wait() {
    return await Promise.resolve(1);
  }
}

new Waiter()
  .wait()
  .then(alert); // 1 (this is the same as (result => alert(result)))

其意義相同:它確保回傳值是一個 Promise,並啟用 await

錯誤處理

如果一個 Promise 正常解析,則 await promise 會回傳結果。但在被拒絕的情況下,它會擲出錯誤,就像在該行有一個 throw 陳述式一樣。

這段程式碼

async function f() {
  await Promise.reject(new Error("Whoops!"));
}

…等同於這段程式碼

async function f() {
  throw new Error("Whoops!");
}

在實際情況中,Promise 可能需要一些時間才會被拒絕。在這種情況下,await 會在擲出錯誤之前有一段延遲。

我們可以使用 try..catch 來捕捉該錯誤,就像一般的 throw 一樣

async function f() {

  try {
    let response = await fetch('http://no-such-url');
  } catch(err) {
    alert(err); // TypeError: failed to fetch
  }
}

f();

如果發生錯誤,控制權會跳到 catch 區塊。我們也可以包裝多行

async function f() {

  try {
    let response = await fetch('/no-user-here');
    let user = await response.json();
  } catch(err) {
    // catches errors both in fetch and response.json
    alert(err);
  }
}

f();

如果我們沒有 try..catch,則非同步函式 f() 的呼叫產生的 Promise 會變成被拒絕的狀態。我們可以附加 .catch 來處理它

async function f() {
  let response = await fetch('http://no-such-url');
}

// f() becomes a rejected promise
f().catch(alert); // TypeError: failed to fetch // (*)

如果我們忘記在那裡新增 .catch,則我們會收到一個未處理的 Promise 錯誤(可以在主控台中檢視)。我們可以使用全域性的 unhandledrejection 事件處理常式來捕捉此類錯誤,如 使用 Promise 進行錯誤處理 一章所述。

async/awaitpromise.then/catch

當我們使用 async/await 時,我們很少需要 .then,因為 await 會為我們處理等待。而且我們可以使用一般的 try..catch 而不是 .catch。這通常(但並非總是)更方便。

但在程式碼的最上層,當我們在任何 async 函式之外時,我們在語法上無法使用 await,因此在 (*) 行中,將 .then/catch 新增到處理最終結果或穿透式錯誤是一種正常的做法,就像上面的範例一樣。

async/awaitPromise.all 搭配使用效果很好

當我們需要等待多個 Promise 時,我們可以將它們包裝在 Promise.all 中,然後 await

// wait for the array of results
let results = await Promise.all([
  fetch(url1),
  fetch(url2),
  ...
]);

如果發生錯誤,它會像往常一樣從失敗的 Promise 傳播到 Promise.all,然後變成一個例外,我們可以使用呼叫周圍的 try..catch 來捕捉它。

摘要

函式之前的 async 關鍵字有兩個效果

  1. 讓它總是回傳一個 Promise。
  2. 允許在其中使用 await

await 關鍵字在 Promise 之前會讓 JavaScript 等候該 Promise 完成,然後

  1. 如果發生錯誤,會產生例外狀況,就像在該處呼叫 throw error 一樣。
  2. 否則,會傳回結果。

它們一起提供一個很棒的架構,用於撰寫易於閱讀和撰寫的非同步程式碼。

使用 async/await 時,我們很少需要撰寫 promise.then/catch,但我們仍不應忘記它們是基於 Promise,因為有時(例如在外層範圍)我們必須使用這些方法。此外,當我們同時等候多項任務時,Promise.all 很好用。

任務

使用 async/await 而不是 .then/catch,重新撰寫〈Promise 串接〉章節中的這個範例程式碼

function loadJson(url) {
  return fetch(url)
    .then(response => {
      if (response.status == 200) {
        return response.json();
      } else {
        throw new Error(response.status);
      }
    });
}

loadJson('https://javascriptinfo.dev.org.tw/no-such-user.json')
  .catch(alert); // Error: 404

註解在程式碼下方

async function loadJson(url) { // (1)
  let response = await fetch(url); // (2)

  if (response.status == 200) {
    let json = await response.json(); // (3)
    return json;
  }

  throw new Error(response.status);
}

loadJson('https://javascriptinfo.dev.org.tw/no-such-user.json')
  .catch(alert); // Error: 404 (4)

註解

  1. 函式 loadJson 變成 async

  2. 內部的所有 .then 都用 await 取代。

  3. 我們可以 return response.json() 而不是等候它,如下所示

    if (response.status == 200) {
      return response.json(); // (3)
    }

    然後外部程式碼必須 await 該 Promise 才能解析。在我們的案例中,這並不重要。

  4. loadJson 拋出的錯誤由 .catch 處理。我們無法在那裡使用 await loadJson(…),因為我們不在 async 函式中。

以下是你可以在「rethrow」範例中找到的內容。使用 async/await 而不是 .then/catch 重新撰寫它。

而且,在 demoGithubUser 中,用迴圈取代遞迴:使用 async/await 可以輕鬆做到這一點。

class HttpError extends Error {
  constructor(response) {
    super(`${response.status} for ${response.url}`);
    this.name = 'HttpError';
    this.response = response;
  }
}

function loadJson(url) {
  return fetch(url)
    .then(response => {
      if (response.status == 200) {
        return response.json();
      } else {
        throw new HttpError(response);
      }
    });
}

// Ask for a user name until github returns a valid user
function demoGithubUser() {
  let name = prompt("Enter a name?", "iliakan");

  return loadJson(`https://api.github.com/users/${name}`)
    .then(user => {
      alert(`Full name: ${user.name}.`);
      return user;
    })
    .catch(err => {
      if (err instanceof HttpError && err.response.status == 404) {
        alert("No such user, please reenter.");
        return demoGithubUser();
      } else {
        throw err;
      }
    });
}

demoGithubUser();

這裡沒有任何技巧。只要在 demoGithubUser 內部用 try..catch 取代 .catch,並在需要的地方加入 async/await

class HttpError extends Error {
  constructor(response) {
    super(`${response.status} for ${response.url}`);
    this.name = 'HttpError';
    this.response = response;
  }
}

async function loadJson(url) {
  let response = await fetch(url);
  if (response.status == 200) {
    return response.json();
  } else {
    throw new HttpError(response);
  }
}

// Ask for a user name until github returns a valid user
async function demoGithubUser() {

  let user;
  while(true) {
    let name = prompt("Enter a name?", "iliakan");

    try {
      user = await loadJson(`https://api.github.com/users/${name}`);
      break; // no error, exit loop
    } catch(err) {
      if (err instanceof HttpError && err.response.status == 404) {
        // loop continues after the alert
        alert("No such user, please reenter.");
      } else {
        // unknown error, rethrow
        throw err;
      }
    }
  }


  alert(`Full name: ${user.name}.`);
  return user;
}

demoGithubUser();

我們有一個稱為 f 的「一般」函式。你如何呼叫 async 函式 wait(),並在 f 內部使用它的結果?

async function wait() {
  await new Promise(resolve => setTimeout(resolve, 1000));

  return 10;
}

function f() {
  // ...what should you write here?
  // we need to call async wait() and wait to get 10
  // remember, we can't use "await"
}

附註:這個任務在技術上非常簡單,但對於不熟悉 async/await 的開發人員來說,這個問題很常見。

這就是了解其內部運作方式有幫助的時候。

只要將 async 呼叫視為 Promise,並將 .then 附加到它即可

async function wait() {
  await new Promise(resolve => setTimeout(resolve, 1000));

  return 10;
}

function f() {
  // shows 10 after 1 second
  wait().then(result => alert(result));
}

f();
教學地圖

留言

留言前請先閱讀…
  • 如果您有改善建議 - 請 提交 GitHub 議題 或提交 Pull Request,而不是留言。
  • 如果您無法理解文章中的內容 - 請詳細說明。
  • 若要插入幾行程式碼,請使用 <code> 標籤,若要插入多行程式碼,請將它們包覆在 <pre> 標籤中,若要插入超過 10 行程式碼,請使用沙盒 (plnkrjsbincodepen…)