給心急的程式設計師的 JavaScript(ES2022 版)
請支持這本書:購買捐款
(廣告,請不要封鎖。)

41 非同步函式



粗略來說,非同步函式為使用 Promise 的程式碼提供更好的語法。因此,為了使用非同步函式,我們應該了解 Promise。它們在上一章中有說明。

41.1 非同步函式:基礎

考慮以下非同步函式

async function fetchJsonAsync(url) {
  try {
    const request = await fetch(url); // async
    const text = await request.text(); // async
    return JSON.parse(text); // sync
  }
  catch (error) {
    assert.fail(error);
  }
}

前一個看起來相當同步的程式碼等同於以下直接使用 Promise 的程式碼

function fetchJsonViaPromises(url) {
  return fetch(url) // async
  .then(request => request.text()) // async
  .then(text => JSON.parse(text)) // sync
  .catch(error => {
    assert.fail(error);
  });
}

關於非同步函式 fetchJsonAsync() 的一些觀察

fetchJsonAsync()fetchJsonViaPromises() 的呼叫方式完全相同,如下所示

fetchJsonAsync('http://example.com/person.json')
.then(obj => {
  assert.deepEqual(obj, {
    first: 'Jane',
    last: 'Doe',
  });
});

  非同步函式與直接使用 Promise 的函式一樣都是基於 Promise

從外部來看,幾乎不可能區分非同步函式和傳回 Promise 的函式。

41.1.1 非同步建構

JavaScript 有以下非同步版本的同步可呼叫實體。它們的角色永遠都是實際函式或方法。

// Async function declaration
async function func1() {}

// Async function expression
const func2 = async function () {};

// Async arrow function
const func3 = async () => {};

// Async method definition in an object literal
const obj = { async m() {} };

// Async method definition in a class definition
class MyClass { async m() {} }

  非同步函式與非同步函式

非同步函式非同步函式 這兩個術語之間的差異很細微,但很重要

41.2 從非同步函式傳回

41.2.1 非同步函式永遠傳回 Promise

每個非同步函式永遠傳回 Promise。

在非同步函式內,我們透過 return(A 行)完成結果 Promise

async function asyncFunc() {
  return 123; // (A)
}

asyncFunc()
.then(result => {
  assert.equal(result, 123);
});

和往常一樣,如果我們沒有明確傳回任何內容,則會為我們傳回 undefined

async function asyncFunc() {
}

asyncFunc()
.then(result => {
  assert.equal(result, undefined);
});

我們透過 throw(A 行)拒絕結果 Promise

async function asyncFunc() {
  throw new Error('Problem!'); // (A)
}

asyncFunc()
.catch(err => {
  assert.deepEqual(err, new Error('Problem!'));
});

41.2.2 傳回的 Promise 沒有包裝

如果我們從非同步函式傳回 Promise p,則 p 會變成函式的結果(或者更確切地說,結果會「鎖定」在 p 上,並表現得與它完全相同)。也就是說,Promise 沒有再包裝在另一個 Promise 中。

async function asyncFunc() {
  return Promise.resolve('abc');
}

asyncFunc()
.then(result => assert.equal(result, 'abc'));

回想一下,任何 Promise q 在以下情況中都會受到類似處理

41.2.3 執行非同步函式:同步啟動,非同步解決(進階)

非同步函式執行方式如下

請注意,結果 p 解決的通知會非同步發生,這與 Promise 的情況相同。

以下程式碼示範非同步函式會同步啟動(A 行),然後目前的任務結束(C 行),接著結果 Promise 會非同步解決(B 行)。

async function asyncFunc() {
  console.log('asyncFunc() starts'); // (A)
  return 'abc';
}
asyncFunc().
then(x => { // (B)
  console.log(`Resolved: ${x}`);
});
console.log('Task ends'); // (C)

// Output:
// 'asyncFunc() starts'
// 'Task ends'
// 'Resolved: abc'

41.3 await:使用 Promise

await 營運子只能在非同步函式和非同步產生器內使用(在 §42.2「非同步產生器」 中說明)。它的運算元通常是 Promise,並會執行下列步驟

請繼續閱讀,深入了解 await 如何處理不同狀態的 Promise。

41.3.1 await 和已完成的 Promise

如果運算元最後變成已完成的 Promise,await 會傳回其完成值

assert.equal(await Promise.resolve('yes!'), 'yes!');

非 Promise 值也允許,而且會直接傳遞(同步,不會暫停非同步函式)

assert.equal(await 'yes!', 'yes!');

41.3.2 await 和已拒絕的 Promise

如果運算元是已拒絕的 Promise,await 會擲出拒絕值

try {
  await Promise.reject(new Error());
  assert.fail(); // we never get here
} catch (e) {
  assert.equal(e instanceof Error, true);
}

  練習:透過非同步函式擷取 API

練習/非同步函式/fetch_json2_test.mjs

41.3.3 await 是淺層的(我們無法在回呼函式中使用它)

如果我們在非同步函式中,並想要透過 await 暫停它,我們必須直接在該函式內執行;我們無法在巢狀函式(例如回呼函式)中使用它。也就是說,暫停是淺層的。

例如,以下程式碼無法執行

async function downloadContent(urls) {
  return urls.map((url) => {
    return await httpGet(url); // SyntaxError!
  });
}

原因是正常的箭頭函式不允許在函式主體內使用 await

好的,讓我們試試非同步箭頭函式

async function downloadContent(urls) {
  return urls.map(async (url) => {
    return await httpGet(url);
  });
}

唉,這也不行:現在 .map()(因此 downloadContent())傳回一個包含 Promise 的陣列,而不是包含(已解開)值的陣列。

一個可能的解決方案是使用 Promise.all() 來解開所有 Promise

async function downloadContent(urls) {
  const promiseArray = urls.map(async (url) => {
    return await httpGet(url); // (A)
  });
  return await Promise.all(promiseArray);
}

這個程式碼可以改進嗎?可以:在 A 行中,我們透過 await 解開一個 Promise,只為了透過 return 立即重新包裝它。如果我們省略 await,我們甚至不需要非同步箭頭函式

async function downloadContent(urls) {
  const promiseArray = urls.map(
    url => httpGet(url));
  return await Promise.all(promiseArray); // (B)
}

基於相同的原因,我們也可以在 B 行中省略 await

41.3.4 在模組的頂層使用 await [ES2022]

我們可以在模組的頂層使用 await,例如

let lodash;
try {
  lodash = await import('https://primary.example.com/lodash');
} catch {
  lodash = await import('https://secondary.example.com/lodash');
}

有關此功能的更多資訊,請參閱 §27.14「模組中的頂層 await [ES2022]」

  練習:非同步對應和篩選

練習/非同步函式/map_async_test.mjs

41.4 (進階)

所有剩餘的章節都是進階的。

41.5 並行性和 await

接下來的兩個小節 中,我們將使用輔助函式 paused()

/**
 * Resolves after `ms` milliseconds
 */
function delay(ms) {
  return new Promise((resolve, _reject) => {
    setTimeout(resolve, ms);
  });
}
async function paused(id) {
  console.log('START ' + id);
  await delay(10); // pause
  console.log('END ' + id);
  return id;
}

41.5.1 await:循序執行非同步函式

如果我們在多個非同步函式的呼叫前面加上 await,則這些函式將循序執行

async function sequentialAwait() {
  const result1 = await paused('first');
  assert.equal(result1, 'first');
  
  const result2 = await paused('second');
  assert.equal(result2, 'second');
}

// Output:
// 'START first'
// 'END first'
// 'START second'
// 'END second'

也就是說,paused('second') 只有在 paused('first') 完全結束後才會開始。

41.5.2 await:並行執行非同步函式

如果我們想要並行執行多個函式,可以使用工具方法 Promise.all()

async function concurrentPromiseAll() {
  const result = await Promise.all([
    paused('first'), paused('second')
  ]);
  assert.deepEqual(result, ['first', 'second']);
}

// Output:
// 'START first'
// 'START second'
// 'END first'
// 'END second'

在此,兩個非同步函式會同時開始。一旦兩個都解決,await 會給我們一個完成值陣列,或者(如果至少一個 Promise 被拒絕)一個例外。

回想 §40.6.2「並行處理秘訣:專注於作業開始時間」,重點在於我們何時開始基於 Promise 的運算,而不是我們如何處理其結果。因此,以下程式碼與前一個一樣「並行」

async function concurrentAwait() {
  const resultPromise1 = paused('first');
  const resultPromise2 = paused('second');
  
  assert.equal(await resultPromise1, 'first');
  assert.equal(await resultPromise2, 'second');
}
// Output:
// 'START first'
// 'START second'
// 'END first'
// 'END second'

41.6 使用非同步函式的秘訣

41.6.1 如果我們「啟動後就忘記」,就不需要 await

使用基於 Promise 的函式時,不需要 await;只有在我們想要暫停並等到回傳的 Promise 解決時,我們才需要它。如果我們只想啟動非同步作業,那麼我們不需要它

async function asyncFunc() {
  const writer = openFile('someFile.txt');
  writer.write('hello'); // don’t wait
  writer.write('world'); // don’t wait
  await writer.close(); // wait for file to close
}

在此程式碼中,我們不會等待 .write(),因為我們不在乎它何時完成。但是,我們確實想要等到 .close() 完成。

注意:每次呼叫 .write() 都會同步啟動。這可以防止競爭情況。

41.6.2 await 並忽略結果是有道理的

偶爾使用 await 是有道理的,即使我們忽略其結果,例如

await longRunningAsyncOperation();
console.log('Done!');

在此,我們使用 await 加入一個長時間執行的非同步作業。這可確保記錄確實在該作業完成之後發生。