非同步函式:讓承諾易於使用

非同步函式可讓您編寫以承諾為基準的程式碼,就像同步作業一樣。

Jake Archibald
Jake Archibald

根據預設,Chrome、Edge、Firefox 和 Safari 都會啟用非同步函式,而且這些函式相當出色。您可以使用這些方法,以同步方式編寫以承諾為基礎的程式碼,但不會封鎖主執行緒。這類程式碼會讓非同步程式碼變得「不夠聰明」,但更易於閱讀。

非同步函式的運作方式如下:

async function myFirstAsyncFunction() {
  try {
    const fulfilledValue = await promise;
  } catch (rejectedValue) {
    // …
  }
}

如果您在函式定義前使用 async 關鍵字,就可以在函式中使用 awaitawait 承諾時,函式會以非阻塞的方式暫停,直到承諾解決為止。如果承諾滿足 交易條件就會退還給你如果應許遭到拒絕,系統會擲回遭拒絕的值。

瀏覽器支援

瀏覽器支援

  • Chrome:55。
  • Edge:15,
  • Firefox:52。
  • Safari:10.1。

資料來源

範例:記錄擷取作業

假設您想擷取網址,並將回應記錄為文字。其使用承諾如下:

function logFetch(url) {
  return fetch(url)
    .then((response) => response.text())
    .then((text) => {
      console.log(text);
    })
    .catch((err) => {
      console.error('fetch failed', err);
    });
}

以下是使用非同步函式的相同內容:

async function logFetch(url) {
  try {
    const response = await fetch(url);
    console.log(await response.text());
  } catch (err) {
    console.log('fetch failed', err);
  }
}

它們的行數相同,但所有回呼都消失了。這樣一來,讀起來會更容易,尤其是對不熟悉承諾的使用者而言。

非同步回傳值

無論是否使用 await,非同步函式都會一律傳回承諾。該承諾會根據非同步函式傳回的內容解析,或根據非同步函式擲回的內容拒絕。因此,您可以使用:

// wait ms milliseconds
function wait(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

async function hello() {
  await wait(500);
  return 'world';
}

…呼叫 hello() 會傳回承諾,並透過 "world" 履行

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

...呼叫 foo() 會傳回使用 Error('bar') 拒絕的承諾。

範例:串流回應

在更複雜的範例中,非同步函式的優點會更加明顯。假設您想在記錄區塊時串流回應,並傳回最終大小。

以下為承諾:

function getResponseSize(url) {
  return fetch(url).then((response) => {
    const reader = response.body.getReader();
    let total = 0;

    return reader.read().then(function processResult(result) {
      if (result.done) return total;

      const value = result.value;
      total += value.length;
      console.log('Received chunk', value);

      return reader.read().then(processResult);
    });
  });
}

看看我這位「承諾使用者」Jake Archibald。查看如何自行呼叫 processResult() 來設定非同步迴圈?寫出讓我覺得很聰明的內容。但就像大多數「聰明」的程式碼一樣,您必須盯著程式碼看很久,才能瞭解程式碼的運作方式,就像 90 年代的魔幻立體圖片一樣。

請使用非同步函式再試一次:

async function getResponseSize(url) {
  const response = await fetch(url);
  const reader = response.body.getReader();
  let result = await reader.read();
  let total = 0;

  while (!result.done) {
    const value = result.value;
    total += value.length;
    console.log('Received chunk', value);
    // get the next result
    result = await reader.read();
  }

  return total;
}

所有「智慧」功能都消失了。讓我覺得很自豪的非同步迴圈,已被可靠但乏味的 while 迴圈取代。這樣好多了。日後您將會取得非同步疊代器,可以 for-of 迴圈取代 while 迴圈,讓程式碼更整齊。

其他非同步函式語法

我已經向您展示 async function() {},但 async 關鍵字可搭配其他函式語法使用:

箭頭函式

// map some URLs to json-promises
const jsonPromises = urls.map(async (url) => {
  const response = await fetch(url);
  return response.json();
});

物件方法

const storage = {
  async getAvatar(name) {
    const cache = await caches.open('avatars');
    return cache.match(`/avatars/${name}.jpg`);
  }
};

storage.getAvatar('jaffathecake').then(…);

類別方法

class Storage {
  constructor() {
    this.cachePromise = caches.open('avatars');
  }

  async getAvatar(name) {
    const cache = await this.cachePromise;
    return cache.match(`/avatars/${name}.jpg`);
  }
}

const storage = new Storage();
storage.getAvatar('jaffathecake').then(…);

請留意!避免過於依序

雖然您編寫的程式碼看起來是同步的,但請務必把握機會並行執行。

async function series() {
  await wait(500); // Wait 500ms…
  await wait(500); // …then wait another 500ms.
  return 'done!';
}

上述作業需要 1000 毫秒才能完成,但:

async function parallel() {
  const wait1 = wait(500); // Start a 500ms timer asynchronously…
  const wait2 = wait(500); // …meaning this timer happens in parallel.
  await Promise.all([wait1, wait2]); // Wait for both timers in parallel.
  return 'done!';
}

上述需要 500 毫秒才能完成,因為這兩個等待事件會同時發生。我們來看看實際範例。

範例:依序輸出擷取內容

假設您想擷取一系列網址,並盡快以正確順序記錄這些網址。

深呼吸 - 以下是使用承諾的情況:

function markHandled(promise) {
  promise.catch(() => {});
  return promise;
}

function logInOrder(urls) {
  // fetch all the URLs
  const textPromises = urls.map((url) => {
    return markHandled(fetch(url).then((response) => response.text()));
  });

  // log them in order
  return textPromises.reduce((chain, textPromise) => {
    return chain.then(() => textPromise).then((text) => console.log(text));
  }, Promise.resolve());
}

沒錯,我使用 reduce 連結一系列承諾。我很聰明。不過,這麼做有點「太聰明」,建議您還是不要這麼做。

不過,將上述內容轉換為非同步函式時,很容易會變得過於順序

不建議使用 - 順序太長
async function logInOrder(urls) {
  for (const url of urls) {
    const response = await fetch(url);
    console.log(await response.text());
  }
}
看起來更整齊,但第二個擷取作業必須等到第一個擷取作業完全讀取後才會開始,依此類推。這比並行執行擷取作業的承諾範例慢上許多。所幸,我們有理想的中庸之道。
建議採用 - 最好同時平行處理
function markHandled(...promises) {
  Promise.allSettled(promises);
}

async function logInOrder(urls) {
  // fetch all the URLs in parallel
  const textPromises = urls.map(async (url) => {
    const response = await fetch(url);
    return response.text();
  });

  markHandled(...textPromises);

  // log them in sequence
  for (const textPromise of textPromises) {
    console.log(await textPromise);
  }
}
在本例中,系統會並行擷取及讀取網址,但「聰明」reduce 位元會替換為標準、無聊且易讀的 for 迴圈。

瀏覽器支援的替代方案:產生器

如果您指定支援產生器的瀏覽器 (包括各大瀏覽器的最新版本),可以使用類似 polyfill 的非同步函式。

Babel 是為您執行上述操作的這裡使用 Babel REPL 的範例

我建議您採用轉譯方法,因為只要目標瀏覽器支援非同步函式,您就可以關閉轉譯,但如果您確實不想使用轉譯器,可以使用 Babel 的 polyfill,自行使用。而不是這樣

async function slowEcho(val) {
  await wait(1000);
  return val;
}

…您會加入 polyfill 並寫入:

const slowEcho = createAsyncFunction(function* (val) {
  yield wait(1000);
  return val;
});

請注意,您必須將產生器 (function*) 傳遞至 createAsyncFunction,並使用 yield 而非 await。除了這個差異,其他運作方式都相同。

解決方法:重新產生器

如果您指定舊版瀏覽器,Babel 也可以轉譯產生器,讓您在 IE8 以下版本中使用非同步函式。為此,您需要使用 Babel 的 es2017 預設設定 以及 es2015 預設設定

輸出內容不太美觀,因此請留意程式碼膨脹問題。

同步處理所有內容!

非同步函式在所有瀏覽器上推出後,請在每個承諾傳回函式上使用這些函式!這不僅可讓程式碼更整齊,還可確保函式「一律」傳回承諾。

在 2014 年,我對非同步函式感到非常興奮,很高興看到這些函式真的在瀏覽器中實現。太好了!