[譯] 經過一些例子深刻了解 JavaScript 的 Async 和 Await

首先來了解下回調函數。回調函數會在被調用後的某一時刻執行,除此以外與其餘普通函數並沒有差異。因爲 JavaScript 的異步特徵,在一些不能當即得到函數返回值的地方都須要使用回調函數。javascript

下面是一個 Node.js 讀取文件時的示例(異步操做)——前端

fs.readFile(__filename, 'utf-8', (err, data) => {
  if (err) {
    throw err;
  }
  console.log(data);
});
複製代碼

但當咱們要處理多重異步操做時問題就會凸顯出來。假設有下面的應用場景(其中的全部操做都是異步的)——java

  • 在數據庫中查找用戶 Arfat,讀取 profile_img_url 數據,而後把圖片從 someServer.com 上下載下來。
  • 在獲取圖片以後,咱們將其轉換成其它不一樣的格式,好比把 PNG 格式轉換至 JPEG 格式。
  • 若是圖片格式轉換成功,則向用戶 Arfat 發送 email。
  • 將這次任務記錄在文件 transformations.log 並加上時間戳。

上述過程的代碼大體以下 ——android

回調地獄示例。

注意回調函數的嵌套和程序末尾 }) 的層級。 鑑於結構上的類似性,這種方式被形象地稱做回調地獄回調金字塔。這種方式的一些缺點是 ——ios

  • 不得不從左至右去理解代碼,使得代碼變得更難以閱讀。
  • 錯誤處理變得更加複雜,而且容易引起錯誤代碼。

爲了解決上述問題,JavaScript 提出了 Promise如今,咱們可使用鏈式結構取代回調函數嵌套的結構。下面是一個例子 ——git

使用 promise

回調流程由從左至右結構變成咱們所熟悉的自上而下的結構,這是一個優勢。可是 promise 仍然有一些缺點 ——es6

  • 咱們仍然得在每個 .then 中處理回調。
  • 不一樣於使用 try/catch,咱們須要使用 .catch 處理錯誤。
  • 在循環體中順序執行多個 promise 具備挑戰且不直觀。

爲了證實上面的最後一個缺點,嘗試一下下面的挑戰吧!github

挑戰

假設要在 for 循環中以任意時間間隔(0 到 n 秒)輸出數字 0 到 10。咱們將使用 promise 去順序打印 0 到 10,好比打印 0 須要 6 秒,打印 1 要延遲 2 秒,而 1 須要 0 打印完成以後才能打印,其它數字打印過程也相似。數據庫

固然,不要使用 async/await.sort 方法,隨後咱們將會解決這一問題。json

Async 函數

async 函數在 ES2017 (ES8) 中引入,使得 promise 的應用更加簡單。

  • 注意到 async 函數是基於 promise 實現的這一點很重要。
  • async/await 並非徹底全新的概念。
  • async/await 能夠被理解爲基於 promise 實現異步方案的一種替代方案。
  • 咱們可使用 async/await 來避免鏈式調用 promise
  • async/await 容許代碼異步執行的同時保持正常的、同步式的感受

所以,在理解 async/await 概念以前你必需要對 promise 有所瞭解

語法

async/await 包含兩個關鍵字 async 和 await。async 用來使得函數能夠異步執行async 使得在函數中可使用 await 關鍵字,除此以外,在任何地方使用 await 都屬於語法錯誤。

// 應用到普通的聲明函數

async function myFn() {
  // await ...
}

// 應用到箭頭函數

const myFn = async () => {
  // await ...
}

function myFn() {
  // await fn(); (Syntax Error since no async) 
}
複製代碼

注意,在函數聲明中 async 關鍵字位於聲明的前面。在箭頭函數中,async 關鍵字則位於 = 和圓括號的中間。

async 函數還能做爲對象的方法,或是像下面代碼同樣位於類中。

// 做爲對象方法

const obj = {
  async getName() {
    return fetch('https://www.example.com');
  }
}

// 位於類中

class Obj {
  async getResource() {
    return fetch('https://www.example.com');
  }
}
複製代碼

注意:類的構造函數和 getters/setters 不能做爲 async 函數。

語義和評估準則

async 函數與普通 JavaScript 函數相比有如下區別 ——

async 函數老是返回 promise 對象。

async function fn() {
  return 'hello';
}

fn().then(console.log)
// hello
複製代碼

函數 fn 的返回值 'hello',因爲咱們使用了 async 關鍵字,返回值 'hello' 被包裝成了一個 promise 對象(經過 Promise.resolve 實現)。

所以,不使用 async 關鍵字的具備同等做用的替代方案可寫做 ——

function fn() {
  return Promise.resolve('hello');
}

fn().then(console.log);
// hello
複製代碼

在上面的代碼中咱們手動返回了一個 promise 對象用於替換 async 關鍵字。

確切地說,async 函數的返回值將會被傳遞到 Promise.resolve 方法中。

若是返回值是一個原始值,Promise.resolve 則返回該值的一個 promise 版本。可是,若是返回值是 promise 對象,那麼 Promise.resolve原封不動地返回這個對象

// 返回值是原始值的狀況

const p = Promise.resolve('hello')
p instanceof Promise; 
// true

//p 被原封不動地返回

Promise.resolve(p) === p; 
// true
複製代碼

在 async 函數中拋出一個錯誤會發生什麼?

好比 ——

async function foo() {
  throw Error('bar');
}

foo().catch(console.log);
複製代碼

若是錯誤未被捕獲foo() 函數會返回一個狀態爲 rejected 的 promise。不一樣於 Promise.resolvePromise.reject 會包裝錯誤並返回。詳情請看稍後的錯誤處理部分。

最終結果是,不論你想要返回什麼結果,最終在 async 函數外,你都會獲得一個 promise。

async 函數在執行 await <表達式>時會停止

await 命令就像一個表達式同樣。當 await 後面跟着一個 promise 時,async 函數遇到 await 會停止運行,直到相應的 promise 狀態變成 resolved。當 await 後面跟的是原始值時,原始值會被傳入 Promise.resolve 而轉變成一個 promise 對象,而且狀態爲 resolved。

// 多功能函數:獲取隨機值/延時

const delayAndGetRandom = (ms) => {
  return new Promise(resolve => setTimeout(
    () => {
      const val = Math.trunc(Math.random() * 100);
      resolve(val);
    }, ms
  ));
};

async function fn() {
  const a = await 9;
  const b = await delayAndGetRandom(1000);
  const c = await 5;
  await delayAndGetRandom(1000);
  
  return a + b * c;
}

// 執行函數 fn
fn().then(console.log);
複製代碼

讓咱們來逐行檢驗函數 fn ——

  • 當函數 fn 被調用時,首先被執行的是 const a = await 9;。它被隱式地轉換成 const a = await Promise.resolve(9);

  • 因爲咱們使用了 await 命令,fn 函數會在此時會暫停到變量 a 得到值爲止。在該狀況下 Promise.resolve 方法返回值爲 9。

  • delayAndGetRandom(1000) 函數使得 fn 中的其它程序暫停執行,直到 1 秒鐘以後 delayAndGetRandom 狀態轉變成 resolved。因此,fn 函數的執行有效地暫停了 1 秒鐘。

  • 此外,delayAndGetRandom 中的 resolve 函數返回一個隨機值。不管往 resolve 函數中傳入什麼值, 都會賦值給變量 b

  • 一樣,變量 c 值爲 5 ,而後使用 await delayAndGetRandom(1000) 又延時了 1 秒鐘。在這個例子中咱們並無使用 Promise.resolve 返回值。

  • 最後咱們計算 a + b * c 的結果,經過 Promise.resolve 將該結果包裝成一個 promise,並將其做爲 async 函數的返回值。

注意: 若是上面程序的暫停和恢復操做讓你想起了 ES6 的 generator,那是由於 generator 也有不少優勢

解決方案

讓咱們使用 async/await 解決在前面提出的假設問題 ——

使用 async/await

咱們定義了一個 async 函數 finishMyTask,使用 await 去等待 queryDatabasesendEmaillogTaskInFile 的操做結果。

若是咱們將 async/await 解決方案與使用 promise 的方案進行對比以後會發現代碼的數量很相近。可是 async/await 使得代碼在語法複雜性方面變得更簡單,不用去記憶多層回調函數以及 .then /.catch

如今,就讓咱們解決上面所列的打印數字的挑戰。下面是兩種不一樣的解決方法 ——

const wait = (i, ms) => new Promise(resolve => setTimeout(() => resolve(i), ms));

// 方法一(使用 for 循環)
const printNumbers = () => new Promise((resolve) => {
  let pr = Promise.resolve(0);
  for (let i = 1; i <= 10; i += 1) {
    pr = pr.then((val) => {
      console.log(val);
      return wait(i, Math.random() * 1000);
    });
  }
  resolve(pr);
});

// 方法二(使用回調)

const printNumbersRecursive = () => {
  return Promise.resolve(0).then(function processNextPromise(i) {

    if (i === 10) {
      return undefined;
    }

    return wait(i, Math.random() * 1000).then((val) => {
      console.log(val);
      return processNextPromise(i + 1);
    });
  });
};
複製代碼

你能夠在 repl.it console 上運行上面的代碼。

若是容許你使用 async 函數,那麼這個挑戰解決起來將會簡單得多。

async function printNumbersUsingAsync() {
  for (let i = 0; i < 10; i++) {
    await wait(i, Math.random() * 1000);
    console.log(i);
  }
}
複製代碼

一樣,該方法也能夠在 repl.it console 上運行。

錯誤處理

如同咱們在語法部分所見,一個未捕獲Error() 被包裝在一個 rejected promise 中。可是,咱們能夠在 async 函數中同步地使用 try-catch 處理錯誤。讓咱們從這一實用的函數開始 ——

async function canRejectOrReturn() {
  // 等待一秒
  await new Promise(res => setTimeout(res, 1000));

// 50% 的可能性是 Rejected 狀態
  if (Math.random() > 0.5) {
    throw new Error('Sorry, number too big.')
  }

return 'perfect number';
}
複製代碼

canRejectOrReturn() 是一個 async 函數,他可能返回 'perfect number' 也可能拋出錯誤('Sorry, number too big')。

咱們來看看示例代碼 ——

async function foo() {
  try {
    await canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}
複製代碼

由於咱們在等待執行 canRejectOrReturn 函數的時候,canRejectOrReturn 函數體內的 promise 會轉移到 rejected 狀態而拋出錯誤,這將致使 catch 代碼塊被執行。也就是說 foo 函數運行結果爲 rejected,返回值爲 undefined(由於咱們在 try 中沒有返回值)或者 'error caught'。由於咱們在 foo 函數中使用了 try-catch 處理錯誤,因此說 foo 函數的結果永遠不會是 rejected。

下面是另一個版本的例子 ——

async function foo() {
  try {
    return canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}
複製代碼

注意這一次咱們使用了 return (而不是 await)將函數 canRejectOrReturnfoo 函數中返回。foo 函數運行結果是 resolved,返回值爲 'perfect number' 或者值爲 Error('Sorry, number too big')。catch 代碼塊永遠都不會被執行。

這是由於函數 foo 返回了 canRejectOrReturn 返回的 promise 對象。所以 foo 的 resolved 變成了 canRejectOrReturn 的 resolved。你能夠將 return canRejectOrReturn() 等價爲下面兩行程序去理解(注意第一行沒有 await)——

try {
    const promise = canRejectOrReturn();
}
複製代碼

讓咱們看看 awaitreturn 搭配使用時的狀況 ——

async function foo() {
  try {
    return await canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}
複製代碼

在上面的例子中,foo 函數運行結果爲 resolved,返回值爲 'perfect number''error caught'foo 函數的結果永遠不會是 rejected。 這就像上面那個只有 await 的例子。只是這裏將函數 canRejectOrReturn 的 rejected 結果返回了,而不是返回了 undefined

你能夠將語句 return await canRejectOrReturn();拆開再看看效果 ——

try {
    const value  = await canRejectOrReturn();
    return value;
}
// ...
複製代碼

常見錯誤和陷阱

因爲涉及 promise 和 async/await 之間錯綜複雜的操做,程序中可能會潛藏一些細微的差錯。讓咱們一塊兒看看吧 ——

沒有使用 await 關鍵字

有時候,在 promise 對象以前咱們忘記了使用 await 關鍵字,或者是忘記將 promise 對象返回。以下所示 ——

async function foo() {
  try {
    canRejectOrReturn();
  } catch (e) {
    return 'caught';
  }
}
複製代碼

注意咱們並無使用 awaitreturnfoo 函數運行結果爲返回值是 undefined 的 resolved,而且函數執行不會延遲 1 秒鐘。可是canRejectOrReturn() 中的 promise 的確被執行了。若是沒有反作用產生,這的確會發生。若是 canRejectOrReturn() 拋出錯誤或者狀態轉移爲 rejected,UnhandledPromiseRejectionWarning 錯誤將會產生。

在回調中使用 async 函數

咱們常常把 async 函數做爲.map.filter 方法的回調。讓咱們舉個例子 — 假設咱們有一個函數 fetchPublicReposCount(username) 能夠獲取一個 github 用戶擁有的公開倉庫的數量。咱們想要得到三名不一樣用戶的公開倉庫數量,讓咱們來看代碼 —

const url = 'https://api.github.com/users';

// 使用 fn 函數獲取倉庫數量
const fetchPublicReposCount = async (username) => {
  const response = await fetch(`${url}/${username}`);
  const json = await response.json();
  return json['public_repos'];
}
複製代碼

想要得到三名用戶 ['ArfatSalman', 'octocat', 'norvig'] 的公開倉庫數量。咱們可能會這樣作 ——

const users = [
  'ArfatSalman',
  'octocat',
  'norvig'
];

const counts = users.map(async username => {
  const count = await fetchPublicReposCount(username);
  return count;
});
複製代碼

過於按順序使用 await

注意 async.map方法中。咱們可能但願變量 counts 存儲着的公開倉庫數量。可是,就如咱們以前所見,全部的 async 函數均返回 promise 對象。 所以,counts 其實是一個 promise 對象數組.map 爲每個 username 調用異步函數,.map 方法將每次調用返回的 promise 結果保存在數組中。

咱們可能也會有其它解決方法,好比 ——

async function fetchAllCounts(users) {
  const counts = [];
  for (let i = 0; i < users.length; i++) {
    const username = users[i];
    const count = await fetchPublicReposCount(username);
    counts.push(count);
  }
  return counts;
}
複製代碼

咱們手動獲取了每個 count,並將它們 append 到 counts 數組中。程序的問題在於第一個用戶的 count 被獲取以後,第二個用戶的 count 才能被獲取。同一時間,只有一個公開倉庫數量能夠被獲取。

若是一個 fetch 操做耗時 300 ms,那麼 fetchAllCounts 函數耗時大概在 900 ms 左右。因而可知,程序耗時會隨着用戶數量的增長而線性增長。由於獲取不一樣用戶公開倉庫數量之間沒有依賴,咱們能夠將操做並行處理。

咱們能夠同時獲取用戶的公開倉庫數量,而不是順序獲取。咱們將使用 .map 方法和 Promise.all

async function fetchAllCounts(users) {
  const promises = users.map(async username => {
    const count = await fetchPublicReposCount(username);
    return count;
  });
  return Promise.all(promises);
}
複製代碼

Promise.all 接受一個 promise 對象數組做爲輸入,返回一個 promise 對象。當全部 promise 對象的狀態都轉變成 resolved 時,返回值爲全部 promise 對應返回值組成的 promise 數組,只要有一個 promise 對象被 rejected,Promise.all 的返回值爲第一個被 rejected 的 promise 對象對應的返回值。可是,同時運行全部 promise 的操做可能行不通。可能你想批量執行 promise。你能夠考慮下使用 p-map 實現受限的併發。

結論

async 函數變得很重要。隨着 Async Iterators 的引入,async 函數將會應用得愈來愈廣。對於現代 JavaScript 開發人員來講深刻理解 async 函數相當重要。我但願這篇文章能對你有所啓發。:)

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索