Async/Await初學(xué)者指南
在JavaScript中,一些操作是異步的。這意味著它們產(chǎn)生的結(jié)果或者值不會(huì)立即奏效。
看看下面的代碼:
function fetchDataFromApi() {
// Data fetching logic here
console.log(data);
}
fetchDataFromApi();
console.log('Finished fetching data');
JavaScript解釋器不會(huì)等待異步fetchDataFromApi函數(shù)完成后再解釋下一條語句。因此,在打印API返回的真實(shí)數(shù)據(jù)之前,它就會(huì)打印Finished fetching data。
大多數(shù)情況下,這并不是我們想要的行為。幸運(yùn)的是,我們可以使用async和await關(guān)鍵字,使我們的程序在繼續(xù)前進(jìn)之前等待異步操作的完成。
這個(gè)功能是在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]獲取一個(gè)編程笑話。API的響應(yīng)是JSON格式的,所以我們在請求完成后提取該響應(yīng)(使用json()方法),然后把這個(gè)笑話打印到控制臺(tái)。
請注意,JokeAPI是第三方API,我們不能保證返回笑話的質(zhì)量。
如果在瀏覽器中運(yùn)行該代碼,或者在Node中(17.5+版本中使用--experimental-fetch)運(yùn)行,我們將看到,事情仍然以錯(cuò)誤的順序打印在控制臺(tái)中。
讓我們來改變它。
async關(guān)鍵字
我們需要做的第一件事是將包含的函數(shù)標(biāo)記為異步的。我們可以通過使用async關(guān)鍵字來做到這一點(diǎn),我們把它放在function關(guān)鍵字的前面:
async function fetchDataFromApi() {
fetch('https://v2.jokeapi.dev/joke/Programming?type=single')
.then(res => res.json())
.then(json => console.log(json.joke));
}
異步函數(shù)總是返回一個(gè)promise(后面會(huì)詳細(xì)介紹),所以可以通過在函數(shù)調(diào)用上鏈接一個(gè)then()來獲得正確的執(zhí)行順序:
fetchDataFromApi()
.then(() => {
console.log('Finished fetching data');
});
如果現(xiàn)在運(yùn)行代碼,看到的結(jié)果會(huì)是這樣的:
If Bill Gates had a dime for every time Windows crashed ... Oh wait, he does.
Finished fetching data
但我們并不想這樣做!JavaScript的promise語法可能會(huì)有點(diǎn)毛糙,而這正是async/await的優(yōu)勢所在:它使我們能夠用一種看起來更像同步代碼的語法來編寫異步代碼,而且更容易閱讀。
await關(guān)鍵字
接下來要做的是,在我們的函數(shù)中的任何異步操作前面加上 await 關(guān)鍵字。這將迫使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');
很不幸,如果嘗試運(yùn)行代碼,會(huì)得到一個(gè)錯(cuò)誤:
Uncaught SyntaxError: await is only valid in async functions, async generators and modules
這是因?yàn)槲覀儾荒茉诜悄K腳本中的async函數(shù)之外使用await。我們將在后面詳細(xì)討論這個(gè)問題,但現(xiàn)在解決這個(gè)問題的最簡單的方法是將調(diào)用的代碼包裹在一個(gè)自己的函數(shù)中,我們也會(huì)將其標(biāo)記為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)在運(yùn)行代碼,一切都如愿:
UDP is better in the COVID era since it avoids unnecessary handshakes.
Finished fetching data
我們需要這個(gè)額外的模板是不幸的,但在我看來,這個(gè)代碼仍然比基于promise的版本更容易閱讀。
聲明異步函數(shù)的不同方式
先前的例子中,使用了兩個(gè)具名函數(shù)聲明(function關(guān)鍵字后跟著函數(shù)名字),但我們并不局限于這些。我們也可以把函數(shù)表達(dá)式、箭頭函數(shù)和匿名函數(shù)標(biāo)記為async。
「異步函數(shù)表達(dá)式」
當(dāng)我們創(chuàng)建一個(gè)函數(shù),并將其賦值給一個(gè)變量時(shí),這便是「函數(shù)表達(dá)式」。該函數(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ù)表達(dá)式的緊湊替代品,并且總是匿名的。它們的基本語法如下:
(params) => { <function body> }
為了標(biāo)記箭頭函數(shù)為匿名的,在左括號(hào)前插入async關(guān)鍵字。
舉個(gè)例子,除了在上面的代碼中創(chuàng)建一個(gè)額外的init函數(shù)外,另一個(gè)辦法是將現(xiàn)有的代碼包裹在一個(gè)IIFE中,我們將其標(biāo)記為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ù)表達(dá)式或函數(shù)聲明并沒有什么大的區(qū)別:大部分情況下,這只是一個(gè)使用偏好的問題。但有幾件事情需要注意,比如變量提升,或者箭頭函數(shù)無法綁定this的事實(shí)。
Await/Async內(nèi)部機(jī)制
正如你可能已經(jīng)猜到的,async/await在很大程度上是promise的語法糖。讓我們更詳細(xì)地看一下這個(gè)問題,因?yàn)楦玫乩斫鈨?nèi)部發(fā)生的事情將對理解async/await的工作方式有很大幫助。
第一件需要注意的事情是,async函數(shù)總是返回一個(gè)promise,即使我們不顯式地告訴它這么做。比如:
async function echo(arg) {
return arg;
}
const res = echo(5);
console.log(res);
打印結(jié)果如下:
Promise { <state>: "fulfilled", <value>: 5 }
promise可能會(huì)是三種狀態(tài)之一:pending、fulfilled、或者rejected。一個(gè)promise開始時(shí)處于pending狀態(tài)。如果與該promise有關(guān)的行為成功了,該promise就被稱為fulfilled。如果行為不成功,該promise就被稱為rejected。一旦promise是fulfilled或者rejected,但不是pending,它也被認(rèn)為是settled。
當(dāng)我們在async函數(shù)中使用 await 關(guān)鍵字來"暫停"函數(shù)執(zhí)行時(shí),真正發(fā)生的是我們在等待一個(gè)promise(無論是顯式還是隱式)進(jìn)入resolved或rejected狀態(tài)。
基于上述示例,我們可以這么做:
async function echo(arg) {
return arg;
}
async function getValue() {
const res = await echo(5);
console.log(res);
}
getValue();
// 5
因?yàn)閑cho函數(shù)返回一個(gè)promise,而getValue函數(shù)中的await關(guān)鍵字在繼續(xù)程序之前等待這個(gè)promise完成,所以我們能夠?qū)⑺璧闹荡蛴〉娇刂婆_(tái)。
promise是對JavaScript中流程控制的一大改進(jìn),并且被一些較新的瀏覽器API所使用。比如Battery status API[3]、Clipboard API[4]、Fetch API[5]、MediaDevices API[6]等等。
Node還在其內(nèi)置的util模塊中添加了一個(gè)promise函數(shù),可以將使用回調(diào)函數(shù)的代碼轉(zhuǎn)換為返回promise。而從v10開始,Node的fs模塊中的函數(shù)可以直接返回promise。
從promise到async/await的轉(zhuǎn)換
那么,為什么這一切對我們來說都很重要呢?
好消息是,任何返回promise的函數(shù)都可以使用async/await。我并不是說我們應(yīng)該對所有的事情都使用async/await(該語法確實(shí)有其缺點(diǎn),我們將在討論錯(cuò)誤處理時(shí)看到),但我們應(yīng)該意識(shí)到這是可能的。
我們已經(jīng)看到了如何改變基于promise的獲取調(diào)用,使之與async/await一起工作,所以讓我們看另一個(gè)例子。這里有一個(gè)小的實(shí)用函數(shù),使用Node基于promise的API和它的readFile方法來獲取一個(gè)文件的內(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就會(huì)變成:
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);
注意:這是在利用一個(gè)叫做top-level await的功能,它只在ES模塊中可用。要運(yùn)行這段代碼,請將文件保存為index.mjs并使用Node>=14.8的版本。
雖然這些都是簡單的例子,但我發(fā)現(xiàn)async/await的語法更容易理解。當(dāng)處理多個(gè)then()語句和錯(cuò)誤處理時(shí),這一點(diǎn)變得尤其真實(shí)。
錯(cuò)誤處理
在處理異步函數(shù)時(shí),有幾種方法來處理錯(cuò)誤。最常見的可能是使用try...catch塊,我們可以把它包在異步操作中并捕捉任何發(fā)生的錯(cuò)誤。
在下面的例子中,請注意我是如何將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');
這將導(dǎo)致以下信息被打印到控制臺(tái):
Something went wrong!
TypeError: fetch failed
...
cause: Error: getaddrinfo ENOTFOUND non-existent-url.dev
Finished fetching data
這種結(jié)果是因?yàn)閒etch返回一個(gè)promise。當(dāng)fetch操作失敗時(shí),promise的reject方法被調(diào)用,await關(guān)鍵字將這種reject轉(zhuǎn)換為一個(gè)可捕捉的錯(cuò)誤。
然而,這種方法有幾個(gè)問題。主要的問題是它很啰嗦,而且相當(dāng)難看。想象一下,我們正在構(gòu)建一個(gè)CRUD應(yīng)用程序,我們?yōu)槊總€(gè)CRUD方法(創(chuàng)建、讀取、更新、銷毀)都有一個(gè)單獨(dú)的函數(shù)。如果這些方法中的每一個(gè)都進(jìn)行了異步API調(diào)用,我們就必須把每個(gè)調(diào)用包在自己的try...catch塊中。這是相當(dāng)多的額外代碼。
另一個(gè)問題是,如果我們不使用await關(guān)鍵字,這將導(dǎo)致一個(gè)未處理的拒絕的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關(guān)鍵字不會(huì)將拒絕的promise轉(zhuǎn)化為可捕捉的錯(cuò)誤。
在函數(shù)調(diào)用中使用catch()
每個(gè)返回promise的函數(shù)都可以利用promise的catch方法來處理任何可能發(fā)生的promise拒絕。
有了這個(gè)簡單的補(bǔ)充,上例中的代碼將優(yōu)雅地處理錯(cuò)誤:
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來恢復(fù)async函數(shù)內(nèi)部的預(yù)期錯(cuò)誤,但通過在調(diào)用函數(shù)中添加catch()來處理意外錯(cuò)誤。
并行運(yùn)行異步命令
當(dāng)我們使用await關(guān)鍵字來等待一個(gè)異步操作完成時(shí),JavaScript解釋器會(huì)相應(yīng)地暫停執(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`)
})();
這里我們正在進(jìn)行兩次API調(diào)用,分別獲取React和Vue的GitHub star數(shù)。雖然這樣可以正常運(yùn)轉(zhuǎn),但我們沒有理由在發(fā)出第二個(gè)fetch請求之前等待第一個(gè)promise完成。如果我們要發(fā)出很多請求,這將是一個(gè)相當(dāng)大的瓶頸。
為了解決這個(gè)問題,我們可以使用Promise.all,它接收一個(gè)promise數(shù)組,并等待所有promise被解決或其中任何一個(gè)承諾被拒絕:
(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
在某些時(shí)候,我們會(huì)嘗試在一個(gè)同步循環(huán)中調(diào)用一個(gè)異步函數(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);
這不會(huì)像預(yù)期的那樣奏效,因?yàn)閒orEach只會(huì)調(diào)用函數(shù)而不等待它完成,以下內(nèi)容將被打印到控制臺(tái):
1000
2000
3000
同樣的事情也適用于其他許多數(shù)組方法,如map、filter和reduce。
幸運(yùn)的是,ES2018引入了異步迭代器,除了它們的next()方法會(huì)返回一個(gè)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請求的例子一樣,這也會(huì)帶來性能上的代價(jià)。for循環(huán)中的每個(gè)await都會(huì)阻塞事件循環(huán),通常應(yīng)該重構(gòu)代碼,一次性創(chuàng)建所有的promise,然后使用Promise.all()來獲取結(jié)果。
甚至有一條ESLint規(guī)則[8],如果它檢測到這種行為就會(huì)警告。
頂層await
最后,讓我們來看看一個(gè)叫做「頂層await」的東西。這是ES2022中引入的語言,從14.8版開始在Node中可用。
當(dāng)我們在文章開頭運(yùn)行我們的代碼時(shí),我們已經(jīng)被這個(gè)東西所要解決的問題給纏住了。還記得這個(gè)錯(cuò)誤嗎?
Uncaught SyntaxError: await is only valid in async functions, async generators and modules
當(dāng)我們試圖在一個(gè)async函數(shù)之外使用await時(shí),就會(huì)發(fā)生這種情況。例如,在我們代碼的頂層:
const ms = await Promise.resolve('Hello, World!');
console.log(msg);
頂層await解決了這個(gè)問題,使上述代碼有效,但只在ES模塊中奏效。如果我們在瀏覽器中工作,我們可以把這段代碼添加到一個(gè)叫做index.js的文件中,然后像這樣把它加載到我們的頁面中:
<script src="index.js" type="module"></script>
事情會(huì)像預(yù)期的那樣工作,不需要包裝函數(shù)或丑陋的IIFE。
在Node中,事情變得更加有趣。要將一個(gè)文件聲明為ES模塊,我們應(yīng)該做兩件事中的一件。一種方法是以.mjs為擴(kuò)展名保存,然后像這樣運(yùn)行它:
node index.mjs
另一種方法是在package.json文件中設(shè)置"type": "module":
{
"name": "myapp",
"type": "module",
...
}
頂層 await 也可以和動(dòng)態(tài)導(dǎo)入很好地配合--一種類函數(shù)的表達(dá)式,它允許我們異步加載 ES 模塊。這將返回一個(gè)promise,而這個(gè)promise將被解析為一個(gè)模塊對象,這意味著我們可以這樣做:
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
動(dòng)態(tài)導(dǎo)入選項(xiàng)也很適合與React和Vue等框架相結(jié)合的懶加載。這使我們能夠減少初始包的大小和交互指標(biāo)的時(shí)間。
總結(jié)
在這篇文章中,我們研究了如何使用async/await來管理你的JavaScript程序的控制流。我們討論了語法、async/await如何工作、錯(cuò)誤處理,以及一些問題。如果你已經(jīng)走到了這一步,你現(xiàn)在就是一個(gè)專家了。 ??
編寫異步代碼可能很難,特別是對初學(xué)者來說,但現(xiàn)在你已經(jīng)對這些技術(shù)有了扎實(shí)的了解,你應(yīng)該能夠運(yùn)用它們來獲得巨大的效果。
- 本文譯自:https://www.sitepoint.com/javascript-async-await/
以上就是本文的全部內(nèi)容,如果對你有所幫助,歡迎點(diǎn)贊、收藏、轉(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