Async/Await初學者指南
在JavaScript中,一些操作是異步的。這意味著它們產(chǎn)生的結(jié)果或者值不會立即奏效。
看看下面的代碼:
function fetchDataFromApi() {
// Data fetching logic here
console.log(data);
}
fetchDataFromApi();
console.log('Finished fetching data');
JavaScript解釋器不會等待異步fetchDataFromApi函數(shù)完成后再解釋下一條語句。因此,在打印API返回的真實數(shù)據(jù)之前,它就會打印Finished fetching data。
大多數(shù)情況下,這并不是我們想要的行為。幸運的是,我們可以使用async和await關鍵字,使我們的程序在繼續(xù)前進之前等待異步操作的完成。
這個功能是在ES2017引入JavaScript的,在所有現(xiàn)代瀏覽器[1]中都支持。
如何創(chuàng)建JavaScript異步函數(shù)
讓我們近距離看看fetchDataFromApi數(shù)據(jù)獲取的邏輯。在JavaScript中,數(shù)據(jù)獲取是典型的異步操作案例。
使用Fetch API,我們可以這么做:
function fetchDataFromApi() {
fetch('https://v2.jokeapi.dev/joke/Programming?type=single')
.then(res => res.json())
.then(json => console.log(json.joke));
}
fetchDataFromApi();
console.log('Finished fetching data');
這里,我們從JokeAPI[2]獲取一個編程笑話。API的響應是JSON格式的,所以我們在請求完成后提取該響應(使用json()方法),然后把這個笑話打印到控制臺。
請注意,JokeAPI是第三方API,我們不能保證返回笑話的質(zhì)量。
如果在瀏覽器中運行該代碼,或者在Node中(17.5+版本中使用--experimental-fetch)運行,我們將看到,事情仍然以錯誤的順序打印在控制臺中。
讓我們來改變它。
async關鍵字
我們需要做的第一件事是將包含的函數(shù)標記為異步的。我們可以通過使用async關鍵字來做到這一點,我們把它放在function關鍵字的前面:
async function fetchDataFromApi() {
fetch('https://v2.jokeapi.dev/joke/Programming?type=single')
.then(res => res.json())
.then(json => console.log(json.joke));
}
異步函數(shù)總是返回一個promise(后面會詳細介紹),所以可以通過在函數(shù)調(diào)用上鏈接一個then()來獲得正確的執(zhí)行順序:
fetchDataFromApi()
.then(() => {
console.log('Finished fetching data');
});
如果現(xiàn)在運行代碼,看到的結(jié)果會是這樣的:
If Bill Gates had a dime for every time Windows crashed ... Oh wait, he does.
Finished fetching data
但我們并不想這樣做!JavaScript的promise語法可能會有點毛糙,而這正是async/await的優(yōu)勢所在:它使我們能夠用一種看起來更像同步代碼的語法來編寫異步代碼,而且更容易閱讀。
await關鍵字
接下來要做的是,在我們的函數(shù)中的任何異步操作前面加上 await 關鍵字。這將迫使JavaScript解釋器"暫停"執(zhí)行并等待結(jié)果。我們可以將這些操作的結(jié)果分配給變量:
async function fetchDataFromApi() {
const res = await fetch('https://v2.jokeapi.dev/joke/Programming?type=single');
const json = await res.json();
console.log(json.joke);
}
我們還需要等待調(diào)用fetchDataFromApi函數(shù)的結(jié)果:
await fetchDataFromApi();
console.log('Finished fetching data');
很不幸,如果嘗試運行代碼,會得到一個錯誤:
Uncaught SyntaxError: await is only valid in async functions, async generators and modules
這是因為我們不能在非模塊腳本中的async函數(shù)之外使用await。我們將在后面詳細討論這個問題,但現(xiàn)在解決這個問題的最簡單的方法是將調(diào)用的代碼包裹在一個自己的函數(shù)中,我們也會將其標記為async:
async function fetchDataFromApi() {
const res = await fetch('https://v2.jokeapi.dev/joke/Programming?type=single');
const json = await res.json();
console.log(json.joke);
}
async function init() {
await fetchDataFromApi();
console.log('Finished fetching data');
}
init();
如果現(xiàn)在運行代碼,一切都如愿:
UDP is better in the COVID era since it avoids unnecessary handshakes.
Finished fetching data
我們需要這個額外的模板是不幸的,但在我看來,這個代碼仍然比基于promise的版本更容易閱讀。
聲明異步函數(shù)的不同方式
先前的例子中,使用了兩個具名函數(shù)聲明(function關鍵字后跟著函數(shù)名字),但我們并不局限于這些。我們也可以把函數(shù)表達式、箭頭函數(shù)和匿名函數(shù)標記為async。
「異步函數(shù)表達式」
當我們創(chuàng)建一個函數(shù),并將其賦值給一個變量時,這便是「函數(shù)表達式」。該函數(shù)是匿名的,這意味著它沒有名字。比如:
const fetchDataFromApi = async function() {
const res = await fetch('https://v2.jokeapi.dev/joke/Programming?type=single');
const json = await res.json();
console.log(json.joke);
}
這將以與我們之前的代碼完全相同的方式工作。
「異步箭頭函數(shù)」
箭頭函數(shù)在ES6被引入。它們是函數(shù)表達式的緊湊替代品,并且總是匿名的。它們的基本語法如下:
(params) => { <function body> }
為了標記箭頭函數(shù)為匿名的,在左括號前插入async關鍵字。
舉個例子,除了在上面的代碼中創(chuàng)建一個額外的init函數(shù)外,另一個辦法是將現(xiàn)有的代碼包裹在一個IIFE中,我們將其標記為async:
(async () => {
async function fetchDataFromApi() {
const res = await fetch('https://v2.jokeapi.dev/joke/Programming?type=single');
const json = await res.json();
console.log(json.joke);
}
await fetchDataFromApi();
console.log('Finished fetching data');
})();
使用函數(shù)表達式或函數(shù)聲明并沒有什么大的區(qū)別:大部分情況下,這只是一個使用偏好的問題。但有幾件事情需要注意,比如變量提升,或者箭頭函數(shù)無法綁定this的事實。
Await/Async內(nèi)部機制
正如你可能已經(jīng)猜到的,async/await在很大程度上是promise的語法糖。讓我們更詳細地看一下這個問題,因為更好地理解內(nèi)部發(fā)生的事情將對理解async/await的工作方式有很大幫助。
第一件需要注意的事情是,async函數(shù)總是返回一個promise,即使我們不顯式地告訴它這么做。比如:
async function echo(arg) {
return arg;
}
const res = echo(5);
console.log(res);
打印結(jié)果如下:
Promise { <state>: "fulfilled", <value>: 5 }
promise可能會是三種狀態(tài)之一:pending、fulfilled、或者rejected。一個promise開始時處于pending狀態(tài)。如果與該promise有關的行為成功了,該promise就被稱為fulfilled。如果行為不成功,該promise就被稱為rejected。一旦promise是fulfilled或者rejected,但不是pending,它也被認為是settled。
當我們在async函數(shù)中使用 await 關鍵字來"暫停"函數(shù)執(zhí)行時,真正發(fā)生的是我們在等待一個promise(無論是顯式還是隱式)進入resolved或rejected狀態(tài)。
基于上述示例,我們可以這么做:
async function echo(arg) {
return arg;
}
async function getValue() {
const res = await echo(5);
console.log(res);
}
getValue();
// 5
因為echo函數(shù)返回一個promise,而getValue函數(shù)中的await關鍵字在繼續(xù)程序之前等待這個promise完成,所以我們能夠?qū)⑺璧闹荡蛴〉娇刂婆_。
promise是對JavaScript中流程控制的一大改進,并且被一些較新的瀏覽器API所使用。比如Battery status API[3]、Clipboard API[4]、Fetch API[5]、MediaDevices API[6]等等。
Node還在其內(nèi)置的util模塊中添加了一個promise函數(shù),可以將使用回調(diào)函數(shù)的代碼轉(zhuǎn)換為返回promise。而從v10開始,Node的fs模塊中的函數(shù)可以直接返回promise。
從promise到async/await的轉(zhuǎn)換
那么,為什么這一切對我們來說都很重要呢?
好消息是,任何返回promise的函數(shù)都可以使用async/await。我并不是說我們應該對所有的事情都使用async/await(該語法確實有其缺點,我們將在討論錯誤處理時看到),但我們應該意識到這是可能的。
我們已經(jīng)看到了如何改變基于promise的獲取調(diào)用,使之與async/await一起工作,所以讓我們看另一個例子。這里有一個小的實用函數(shù),使用Node基于promise的API和它的readFile方法來獲取一個文件的內(nèi)容。
使用Promise.then():
const { promises: fs } = require('fs');
const getFileContents = function(fileName) {
return fs.readFile(fileName, enc)
}
getFileContents('myFile.md', 'utf-8')
.then((contents) => {
console.log(contents);
});
有了async/await就會變成:
import { readFile } from 'node:fs/promises';
const getFileContents = function(fileName, enc) {
return readFile(fileName, enc)
}
const contents = await getFileContents('myFile.md', 'utf-8');
console.log(contents);
注意:這是在利用一個叫做top-level await的功能,它只在ES模塊中可用。要運行這段代碼,請將文件保存為index.mjs并使用Node>=14.8的版本。
雖然這些都是簡單的例子,但我發(fā)現(xiàn)async/await的語法更容易理解。當處理多個then()語句和錯誤處理時,這一點變得尤其真實。
錯誤處理
在處理異步函數(shù)時,有幾種方法來處理錯誤。最常見的可能是使用try...catch塊,我們可以把它包在異步操作中并捕捉任何發(fā)生的錯誤。
在下面的例子中,請注意我是如何將URL改成不存在的東西的:
async function fetchDataFromApi() {
try {
const res = await fetch('https://non-existent-url.dev');
const json = await res.json();
console.log(json.joke);
} catch (error) {
// Handle the error here in whichever way you like
console.log('Something went wrong!');
console.warn(error)
}
}
await fetchDataFromApi();
console.log('Finished fetching data');
這將導致以下信息被打印到控制臺:
Something went wrong!
TypeError: fetch failed
...
cause: Error: getaddrinfo ENOTFOUND non-existent-url.dev
Finished fetching data
這種結(jié)果是因為fetch返回一個promise。當fetch操作失敗時,promise的reject方法被調(diào)用,await關鍵字將這種reject轉(zhuǎn)換為一個可捕捉的錯誤。
然而,這種方法有幾個問題。主要的問題是它很啰嗦,而且相當難看。想象一下,我們正在構(gòu)建一個CRUD應用程序,我們?yōu)槊總€CRUD方法(創(chuàng)建、讀取、更新、銷毀)都有一個單獨的函數(shù)。如果這些方法中的每一個都進行了異步API調(diào)用,我們就必須把每個調(diào)用包在自己的try...catch塊中。這是相當多的額外代碼。
另一個問題是,如果我們不使用await關鍵字,這將導致一個未處理的拒絕的promise:
import { readFile } from 'node:fs/promises';
const getFileContents = function(fileName, enc) {
try {
return readFile(fileName, enc)
} catch (error) {
console.log('Something went wrong!');
console.warn(error)
}
}
const contents = await getFileContents('this-file-does-not-exist.md', 'utf-8');
console.log(contents);
上述代碼的打印如下:
node:internal/process/esm_loader:91
internalBinding('errors').triggerUncaughtException(
^
[Error: ENOENT: no such file or directory, open 'this-file-does-not-exist.md'] {
errno: -2,
code: 'ENOENT',
syscall: 'open',
path: 'this-file-does-not-exist.md'
}
與await不同,return關鍵字不會將拒絕的promise轉(zhuǎn)化為可捕捉的錯誤。
在函數(shù)調(diào)用中使用catch()
每個返回promise的函數(shù)都可以利用promise的catch方法來處理任何可能發(fā)生的promise拒絕。
有了這個簡單的補充,上例中的代碼將優(yōu)雅地處理錯誤:
const contents = await getFileContents('this-file-does-not-exist.md', 'utf-8')
.catch((error) => {
console.log('Something went wrong!');
console.warn(error);
});
console.log(contents);
現(xiàn)在輸出是這樣子的:
Something went wrong!
[Error: ENOENT: no such file or directory, open 'this-file-does-not-exist.md'] {
errno: -2,
code: 'ENOENT',
syscall: 'open',
path: 'this-file-does-not-exist.md'
}
undefined
至于使用哪種策略,我同意Valeri Karpov[7]的建議。使用try/catch來恢復async函數(shù)內(nèi)部的預期錯誤,但通過在調(diào)用函數(shù)中添加catch()來處理意外錯誤。
并行運行異步命令
當我們使用await關鍵字來等待一個異步操作完成時,JavaScript解釋器會相應地暫停執(zhí)行。雖然這很方便,但這可能并不總是我們想要的??紤]一下下面的代碼:
(async () => {
async function getStarCount(repo){
const repoData = await fetch(repo);
const repoJson = await repoData.json()
return repoJson.stargazers_count;
}
const reactStars = await getStarCount('https://api.github.com/repos/facebook/react');
const vueStars = await getStarCount('https://api.github.com/repos/vuejs/core');
console.log(`React has ${reactStars} stars, whereas Vue has ${vueStars} stars`)
})();
這里我們正在進行兩次API調(diào)用,分別獲取React和Vue的GitHub star數(shù)。雖然這樣可以正常運轉(zhuǎn),但我們沒有理由在發(fā)出第二個fetch請求之前等待第一個promise完成。如果我們要發(fā)出很多請求,這將是一個相當大的瓶頸。
為了解決這個問題,我們可以使用Promise.all,它接收一個promise數(shù)組,并等待所有promise被解決或其中任何一個承諾被拒絕:
(async () => {
async function getStarCount(repo){
// As before
}
const reactPromise = getStarCount('https://api.github.com/repos/facebook/react');
const vuePromise = getStarCount('https://api.github.com/repos/vuejs/core');
const [reactStars, vueStars] = await Promise.all([reactPromise, vuePromise]);
console.log(`React has ${reactStars} stars, whereas Vue has ${vueStars} stars`);
})();
好多了!
同步循環(huán)中的異步await
在某些時候,我們會嘗試在一個同步循環(huán)中調(diào)用一個異步函數(shù)。比如說:
// Return promise which resolves after specified no. of milliseconds
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
async function process(array) {
array.forEach(async (el) => {
await sleep(el); // we cannot await promise here
console.log(el);
});
}
const arr = [3000, 1000, 2000];
process(arr);
這不會像預期的那樣奏效,因為forEach只會調(diào)用函數(shù)而不等待它完成,以下內(nèi)容將被打印到控制臺:
1000
2000
3000
同樣的事情也適用于其他許多數(shù)組方法,如map、filter和reduce。
幸運的是,ES2018引入了異步迭代器,除了它們的next()方法會返回一個promise外,它們就像普通的迭代器。這意味著我們可以在其中使用 await。讓我們使用for...of重寫上面的代碼:
async function process(array) {
for (el of array) {
await sleep(el);
console.log(el);
};
}
現(xiàn)在,process函數(shù)的輸出就是正確的順序:
3000
1000
2000
就像我們之前等待異步fetch請求的例子一樣,這也會帶來性能上的代價。for循環(huán)中的每個await都會阻塞事件循環(huán),通常應該重構(gòu)代碼,一次性創(chuàng)建所有的promise,然后使用Promise.all()來獲取結(jié)果。
甚至有一條ESLint規(guī)則[8],如果它檢測到這種行為就會警告。
頂層await
最后,讓我們來看看一個叫做「頂層await」的東西。這是ES2022中引入的語言,從14.8版開始在Node中可用。
當我們在文章開頭運行我們的代碼時,我們已經(jīng)被這個東西所要解決的問題給纏住了。還記得這個錯誤嗎?
Uncaught SyntaxError: await is only valid in async functions, async generators and modules
當我們試圖在一個async函數(shù)之外使用await時,就會發(fā)生這種情況。例如,在我們代碼的頂層:
const ms = await Promise.resolve('Hello, World!');
console.log(msg);
頂層await解決了這個問題,使上述代碼有效,但只在ES模塊中奏效。如果我們在瀏覽器中工作,我們可以把這段代碼添加到一個叫做index.js的文件中,然后像這樣把它加載到我們的頁面中:
<script src="index.js" type="module"></script>
事情會像預期的那樣工作,不需要包裝函數(shù)或丑陋的IIFE。
在Node中,事情變得更加有趣。要將一個文件聲明為ES模塊,我們應該做兩件事中的一件。一種方法是以.mjs為擴展名保存,然后像這樣運行它:
node index.mjs
另一種方法是在package.json文件中設置"type": "module":
{
"name": "myapp",
"type": "module",
...
}
頂層 await 也可以和動態(tài)導入很好地配合--一種類函數(shù)的表達式,它允許我們異步加載 ES 模塊。這將返回一個promise,而這個promise將被解析為一個模塊對象,這意味著我們可以這樣做:
const locale = 'DE';
const { default: greet } = await import(
`${ locale === 'DE' ?
'./de.js' :
'./en.js'
}`
);
greet();
// Outputs "Hello" or "Guten Tag" depending on the value of the locale variable
動態(tài)導入選項也很適合與React和Vue等框架相結(jié)合的懶加載。這使我們能夠減少初始包的大小和交互指標的時間。
總結(jié)
在這篇文章中,我們研究了如何使用async/await來管理你的JavaScript程序的控制流。我們討論了語法、async/await如何工作、錯誤處理,以及一些問題。如果你已經(jīng)走到了這一步,你現(xiàn)在就是一個專家了。 ??
編寫異步代碼可能很難,特別是對初學者來說,但現(xiàn)在你已經(jīng)對這些技術有了扎實的了解,你應該能夠運用它們來獲得巨大的效果。
- 本文譯自:https://www.sitepoint.com/javascript-async-await/
以上就是本文的全部內(nèi)容,如果對你有所幫助,歡迎點贊、收藏、轉(zhuǎn)發(fā)~
參考資料
[1]現(xiàn)代瀏覽器:https://caniuse.com/async-functions
[2]JokeAPI:https://jokeapi.dev/
[3]Battery status API:https://developer.mozilla.org/en-US/docs/Web/API/Battery_Status_API
[4]Clipboard API:https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API
[5]Fetch API:https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
[6]MediaDevices API:https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices
[7]Valeri Karpov:https://thecodebarbarian.com/async-await-error-handling-in-javascript.html
[8]ESLint規(guī)則:https://eslint.org/docs/latest/rules/no-await-in-loop