async
-await
Promise
s
Recap of JavaScript's Promise
abstraction was discussed in the notes near the start of the course and you had a chance to use it on the Make project.
I'll give a quick refresher since Promise
s are essential to the async
-await
syntax.
A Promise
represents an asynchronous task or computation.
When the task or computation finishes, the Promise
"resolves" to a value.
If an error occurs, the Promise
"rejects" with the error.
The usefulness of Promises
comes from the ways they can be composed into more complex Promise
s.
There are two main ways to combine Promise
s:
- In sequence.
.then()
chains twoPromise
s into aPromise
that performs the asynchronous tasks or computations one after another. You can build long chains by calling.then()
more times:// Build a Promise that performs 4 asynchronous computations in sequence promiseA .then(a => { /* build and return promiseB... */ }) .then(b => { /* build and return promiseC... */ }) .then(c => { /* build and return promiseD... */ })
- In parallel.
Promise.all()
combines an array ofPromise
s into aPromise
that performs all the tasks or computations in parallel and resolves to an array containing all their results:// Build a promise that performs 3 asynchronous computations in parallel // and resolves with their results once they have all finished Promise.all([promiseA, promiseB, promiseC])
async
-await
syntax
Promise
s can greatly simplify asynchronous programs.
Frequently, several asynchronous tasks need to happen in sequence.
In this case, .then()
provides an interface that looks closer to how the program would be written in a synchronous language.
Recently, new syntax was added to JavaScript that makes asynchronous code look even more like synchronous code.
It relies on two keywords:
-
async
: we can declare an "async function" by puttingasync
in front of it:async function name(...arguments) { /* ... */ } // or async (...arguments) => { /* ... */ }
async
functions returnPromise
s. If the functionreturn
s a value, thePromise
resolves with that value. If the functionthrow
s an error, thePromise
rejects with that error. -
await
: inside anasync
function, the keywordawait
can be used to "wait" for aPromise
to resolve. If thePromise
throws an error, it can be caught by atry
-catch
block.
It is easiest to explain async
-await
with some examples.
Example: recursively exploring a directory
We can rewrite the code from the Promise
s notes for computing the modification time of a directory.
Recall that we want to find the latest modification time of any file inside the directory (or any subdirectories).
By making modifiedTime()
an async
function, we can use await
instead of explicit .then()
calls on Promise
s.
You can compare this async
-await
version with the Promise
s version.
// Returns a Promise<number> that computes the most recent
// modification time for any file inside a directory
async function modifiedTime(path) {
// First, we wait for the fs.stat() Promise
const stats = await fs.stat(path)
// Use the modification time of the file or directory itself
let latestTime = stats.mtimeMs
// If this is a directory, check all files inside for later modified times
if (stats.isDirectory()) {
// Wait to get a list of all files/subdirectories in the directory
const files = await fs.readdir(path)
// Then get the modified time of each file/subdirectory
for (const file of files) {
const fileModifiedTime = await modifiedTime(path + '/' + file)
// update latestTime if the modification time is later
latestTime = Math.max(latestTime, fileModifiedTime)
}
}
// Return the latest modification time
return latestTime
}
However, this code no longer explores subdirectories in parallel, which was the main advantage of asynchronicity. Can you see why?
The issue is that we have replaced Promise.all(files.map(file => /* ... */))
with a for (const file of files)
loop.
Since await modifiedTime(path + '/' + file)
waits for modifiedTime()
to finish, the loop waits for file
to be completely explored before continuing.
This means the subdirectories are explored sequentially.
If we want to perform asynchronous tasks in parallel inside an async
function, we still need to use Promise.all()
.
Here is a parallel version using Promise.all()
:
async function modifiedTime(path) {
const stats = await fs.stat(path)
const {mtimeMs} = stats
// If this path is a file, return its modification time
if (!stats.isDirectory()) return mtimeMs
// If this is a directory, check all files inside for later modified times
const files = await fs.readdir(path)
// Get the modified time of each file/subdirectory in parallel
const modifiedTimes = await Promise.all(files.map(file =>
modifiedTime(path + '/' + file)
))
// Return the latest modification time
return Math.max(mtimeMs, ...modifiedTimes)
}
Example: emoji search
The Unicode Consortium defines the mapping from numbers to characters (including emoji) that almost everyone uses.
For example, Unicode defines 65 to mean A
, 233 to mean é
, and 128027 to mean 🐛
.
We use this mapping to make an emoji search that finds a Unicode character by name (e.g. pile of poo
) and prints out the character.
We first fetch the current Unicode version (updated each year) from Unicode's ReadMe.txt
file.
We then fetch the current UnicodeData.txt
file which lists the tens of thousands of Unicode characters.
Note how await
allows us to execute 4 Promise
s sequentially in getUnicodeNumber()
without needing to combine them with .then()
.
async function getUnicodeNumber(searchName) {
// Fetch the readme to determine the latest Unicode version (e.g. 13.0.0)
const readmeResponse = await fetch(README_URL)
const readme = await readmeResponse.text() // read readme as a string
// The last line of the readme contains the current Unicode URL,
// e.g. https://www.unicode.org/Public/13.0.0/ucd/
const [latestURL] = readme.trim().split('\n').slice(-1)
// Then fetch the list of Unicode characters
const charactersResponse = await fetch(latestURL + UNICODE_FILE)
const charactersData = await charactersResponse.text()
// Each line of the data file corresponds to one Unicode character
for (const characterData of charactersData.split('\n')) {
// Each line has several fields separated by `;`.
// The first is the character's number and the second is its name.
const [characterNumber, characterName] = characterData.split(';')
if (characterName.toLowerCase() === searchName) {
// If this is the requested character, read its number in base-16
return parseInt(characterNumber, 16)
}
}
return undefined
}
async function printUnicode(searchName) {
// getUnicodeNumber() returns a Promise, so we need to await it
const number = await getUnicodeNumber(searchName)
// Convert the Unicode number to a character
if (number !== undefined) console.log(String.fromCodePoint(number))
}
(fetch()
turns an HTTP/HTTPS request into a Promise
, as explained in the HTTP notes.)
Example: create files without overwriting existing files
When an await
ed Promise
rejects with an error, you can catch it using a try
-catch
block, just like a normal JavaScript error.
For example, suppose we want to create some files but not overwrite any existing files with the same name.
We can use the wx
flag to make fs.writeFile()
reject if a file already exists.
We want either all or none of the files to be created, so if one already exists, we stop and remove all the files that were already created.
// Creates an array of files. If any file already exists,
// no files are created and an error is thrown.
async function writeFiles(files) {
for (let i = 0; i < files.length; i++) {
const file = files[i]
try {
// Write to the file, but throw an error if the file exists
await fs.writeFile(file.name, file.contents, {flag: 'wx'})
}
catch (e) {
// File already exists, so remove all the previously created files
await Promise.all(files.slice(0, i).map(file =>
fs.unlink(file.name)
))
// Re-throw the error
throw e
}
}
}
// Try to create 3 files
writeFiles([
{name: 'a.txt', contents: 'abc'},
{name: 'b.txt', contents: '123'},
{name: 'c.txt', contents: 'xyz'}
])
.catch(_ => console.log('Some files already existed'))
You may notice that the files are not written in parallel.
It is possible to fix this by Promise.all()
ing the fs.writeFile()
s, but this is tricky since the Promise
returned by Promise.all()
rejects as soon as any of the Promise
s reject.
If the the Promise.all()
rejected, we would need to wait for each file to finish being written before trying to remove it.