Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Если вас заинтересовала эта статья, то вы, наверное, несколько разбираетесь в асинхронном программировании на JavaScript и, возможно, интересуетесь, как оно работает в TypeScript.
Поскольку TypeScript – это надмножество JavaScript, async/await там работает точно также, но с некоторыми дополнительными бонусами и безопасностью типов. TypeScript позволяет запрограммировать безопасность типа ожидаемого результата и даже проверить, нет ли ошибок, связанных с типом. Поэтому баги отлавливаются на ранних стадиях разработки программы.
В сущности, async/await – это синтаксический сахар для промисов, то есть, ключевое слово async/await
обертывает промисы. Функция async
всегда возвращает промис. Даже если пропустить ключевое слово Promise
, компилятор обернет вашу функцию в немедленно разрешаемый промис.
Давайте покажу:
const myAsynFunction = async (url: string): Promise<T> => {
const { data } = await fetch(url)
return data
}
const immediatelyResolvedPromise = (url: string) => {
const resultPromise = new Promise((resolve, reject) => {
resolve(fetch(url))
})
return resultPromise
}
Пусть они и выглядят совершенно по-разному, два вышеприведенных фрагмента кода более-менее эквивалентны. Async/await просто позволяет писать код в более синхронной манере и избавляет от необходимости встраивать промис в строку. Это очень мощный прием, если имеешь дело со сложными асинхронными паттернами.
Чтобы выжать максимум из синтаксиса async/await
, нужно иметь базовое представление о промисах. Давайте подробнее рассмотрим, что представляют собой промисы на фундаментальном уровне.
Что такое промис в TypeScript?
В переводе с английского «promise» означает «обещание». В JavaScript промис описывает ожидание того, что некоторое событие произойдет в определенный момент, и ваше приложение полагается на результат этого будущего события при выполнении определенных других задач.
Чтобы показать, что я имею в виду, разберу реалистичный пример, выражу его в псевдокоде, а затем в действующем коде TypeScript.
Допустим, мне нужно покосить газон. Звоню в газонокосильную компанию, где мне обещают, что через пару часов придет человек и покосит газон. Я, в свою очередь, обещаю сразу же ему за это заплатить, при условии, что мой газон будет выкошен как следует.
Заметили паттерн? Первая очевидная вещь, которую нужно отметить – второе событие полностью полагается на первое. Если будет выполнено обещание, заложенное в первом событии, то выполнится и следующее событие. Промис в том событии либо выполняется, либо не выполняется, либо остается в подвешенном состоянии.
Рассмотрим эту последовательность шаг за шагом и выразим ее в коде.
Синтаксис промиса
Прежде, чем написать весь код, давайте разберемся в синтаксисе промиса – конкретно, такого промиса, который разрешается в строку.
Мы объявили promise
при помощи ключевого слова new + Promise
, где промис принимает аргументы resolve
и reject
. Теперь давайте напишем промис, выражающий события из вышеприведенной блок-схемы.
// Я отправляю запрос в компанию. Он синхронный
// Компания обещает мне выполнить работу
const angelMowersPromise = new Promise<string>((resolve, reject) => {
// Обещание разрешилось спустя несколько часов
setTimeout(() => {
resolve('We finished mowing the lawn')
}, 100000) // разрешается спустя 100 000 мс
reject("We couldn't mow the lawn")
})
const myPaymentPromise = new Promise<Record<string, number | string>>((resolve, reject) => {
// разрешившийся промис с объектом: платежом в 1000 евро
// и большое спасибо
setTimeout(() => {
resolve({
amount: 1000,
note: 'Thank You',
})
}, 100000)
// промис отклонен. 0 евро и отзыв «неудовлетворительно»
reject({
amount: 0,
note: 'Sorry Lawn was not properly Mowed',
})
})
В вышеприведенном коде объявлены как обещания компании, так и наши обещания. Обещание компании либо выполняется через 100 000 мс, либо отклоняется. Promise
всегда находится в одном из трех состояний: resolved
, если ошибки нет, rejected
, если встретилась ошибка, или pending
, если обещание promise
пока ни отклонено, ни выполнено. В нашем случае все это укладывается в период 100000ms
.
Но как нам выполнить эту задачу последовательным синхронным образом? Здесь-то и пригодится ключевое слово then
. Без него функции просто выполняются в том же порядке, в котором и разрешаются.
Последовательное выполнение с .then
Теперь можно сцепить промисы, что позволяет выполнять их последовательно с применением .then
. Эти функции похожи на обычный человеческий язык: сделай так, а затем вот это, а потом то и так далее.
angelMowersPromise
.then(() => myPaymentPromise.then(res => console.log(res)))
.catch(error => console.log(error))
Вышеприведенный код выполнит angelMowersPromise
. Если с этим ошибки не случится, он выполнит myPaymentPromise
. Если в одном из двух промисов возникнет ошибка, то она будет отловлена в блоке catch
.
Теперь давайте рассмотрим более технический пример. При программировании клиентского интерфейса есть типичная задача: выполнять запросы по сети и адекватно реагировать на их результаты.
Ниже – запрос, требующий выбрать список сотрудников с удаленного сервера.
const api = 'http://dummy.restapiexample.com/api/v1/employees'
fetch(api)
.then(response => response.json())
.then(employees => employees.forEach(employee => console.log(employee.id)) // логирует id всех сотрудников
.catch(error => console.log(error.message))) // логирует любую ошибку, приходящую от промиса
Бывает так, что необходимо параллельно или последовательно выполнять сразу множество обещаний. В подобных сценариях особенно полезны такие конструкции как Promise.all
или Promise.race
.
Представьте, к примеру, сто нужно выбрать список из 1 000 пользователей GitHub, а затем сделать дополнительный запрос с ID, чтобы выбрать для каждого из них аватарки. Совсем не обязательно вы захотите дожидаться завершения этих операций со всеми пользователями в последовательности; вам нужны только все выбранные аватарки. Мы подробнее поговорим об этом ниже, когда будем обсуждать Promise.all
.
Теперь, когда вы в общем и целом поняли, что такое промисы, давайте рассмотрим синтаксис async/await
.
async/await
Синтаксис Async/await удивительно прост при работе с промисами. Он предоставляет простой интерфейс для чтения и записи промисов, причем, таким образом, что они кажутся синхронными.
Конструкция async/await
всегда возвращает Promise
. Даже если пропустить ключевое слово Promise
, компилятор обернет вашу функцию в немедленно разрешаемый промис. Таким образом, можно трактовать возвращаемое значение функции async как Promise
, что довольно полезно, когда нужно разрешать сразу множество асинхронных функций.
Как понятно из названия, async
с await
всегда ходят парой. То есть, делать await
можно только внутри функции async
. Функция async
сообщает компилятору, что это асинхронная функция.
Если преобразовать вышеприведенные промисы, то получится такой синтаксис:
const myAsync = async (): Promise<Record<string, number | string>> => {
await angelMowersPromise
const response = await myPaymentPromise
return response
}
Сразу заметно, что этот код выглядит более удобочитаемым и кажется синхронным. В строке 3 мы сказали компилятору дожидаться выполнения angelMowersPromise
, и только потом делать что-то еще. Затем возвращаем отклик от myPaymentPromise
.
Возможно, вы заметили, что здесь мы пропустили обработку ошибок. Это можно было бы сделать в блоке catch
после .then
в промисе. Но что делать, если нам попадется ошибка? Это приводит нас к блоку try/catch
.
Обработка ошибок в try/catch
Вернемся к примеру с выбором записей о сотрудниках, чтобы показать обработку ошибок в действии, поскольку именно при выполнении запроса по сети ошибка вполне может возникнуть.
Допустим, например, что у нас лег сервер, либо что мы отправили запрос в неверном формате. Мы должны приостановить выполнение, чтобы предотвратить обвал программы. Синтаксис будет выглядеть так:
interface Employee {
id: number
employee_name: string
employee_salary: number
employee_age: number
profile_image: string
}
const fetchEmployees = async (): Promise<Array<Employee> | string> => {
const api = 'http://dummy.restapiexample.com/api/v1/employees'
try {
const response = await fetch(api)
const { data } = await response.json()
return data
} catch (error) {
if (error) {
return error.message
}
}
}
Мы инициировали функцию async
. В качестве возвращаемого значения ожидаем массив типа typeof
с информацией о сотрудниках, либо строку с сообщениями об ошибке. Соответственно, тип Promise формулируется как Promise<Array<Employee> | string>
.
В блоке try
находятся выражения, которые функция должна выполнять, если ошибок не будет. Блок catch
захватывает любую возникающую ошибку. В таком случае мы просто возвращаем свойство message
объекта error
.
Красота происходящего заключается в том, что любая ошибка, рождающаяся в блоке try, сразу выбрасывается и захватывается блоком catch
. Если какое-то исключение ускользнет, то может получиться код, плохо поддающийся отладке, либо даже может быть испорчена вся программа.
Конкурентное выполнение при помощи Promise.all
Как я говорил ранее, бывает, что обещания должны выполняться параллельно.
Продолжим пример с нашим API для выбора сотрудников. Допустим, нам нужно выбрать всех сотрудников, затем выбрать их имена, затем сгенерировать на основе имен электронные сообщения. Очевидно, нам нужно выполнять эти функции в синхронной манере, но при этом параллельно, чтобы одна функция не блокировала другую.
В данном случае мы воспользуемся Promise.all
. Как пишет Mozilla, “Promise.all
обычно применяется после того, как было запущено множество асинхронных задач, которые должны работать конкурентно, и после того, как пообещали, каковы будут их результаты – чтобы можно было дождаться, пока все эти задачи будут завершены.”
В псевдокоде было бы что-то подобное:
Выбрать всех пользователей =>
/employee
Дождаться всех данных о пользователях. Извлечь
id
от каждого пользователя. Выбрать каждого пользователя =>/employee/{id}
Сгенерировать электронное сообщение для каждого пользователя по его имени
const baseApi = 'https://reqres.in/api/users?page=1'
const userApi = 'https://reqres.in/api/user'
const fetchAllEmployees = async (url: string): Promise<Employee[]> => {
const response = await fetch(url)
const { data } = await response.json()
return data
}
const fetchEmployee = async (url: string, id: number): Promise<Record<string, string>> => {
const response = await fetch(`${url}/${id}`)
const { data } = await response.json()
return data
}
const generateEmail = (name: string): string => {
return `${name.split(' ').join('.')}@company.com`
}
const runAsyncFunctions = async () => {
try {
const employees = await fetchAllEmployees(baseApi)
Promise.all(
employees.map(async user => {
const userName = await fetchEmployee(userApi, user.id)
const emails = generateEmail(userName.name)
return emails
})
)
} catch (error) {
console.log(error)
}
}
runAsyncFunctions()
В вышеприведенном коде fetchEmployees
выбирает всех сотрудников из baseApi
. Мы ожидаем отклик (await
), преобразуем его в JSON
, а затем возвращаем преобразованные данные.
Самое важное, о чем здесь нужно помнить – как мы последовательно выполняли код строка за строкой внутри функции async
с ключевым словом await
. Мы бы получили ошибку, если бы попытались преобразовать в JSON данные, которых дождались не полностью. То же касается fetchEmployee
, с той оговоркой, что выбирали бы всего одного сотрудника. Более интересен фрагмент runAsyncFunctions
, где все асинхронные функции выполняются конкурентно.
Сначала обернем в блок try/catch
все методы, находящиеся внутри runAsyncFunctions
. Далее ждем (await
) результат выбора всех сотрудников. Нам нужен id
каждого сотрудника, чтобы выбрать соответствующие им данные, но в конечном счете нам нужна именно информация о сотрудниках.
Вот где можно прибегнуть к Promise.all
, чтобы конкурентно обработать все Promises
. Каждый fetchEmployee Promise
конкурентно выполняется для всех сотрудников. Информация о сотрудниках, которую мы дождемся, используется для генерации электронного сообщения от каждого сотрудника, это делается при помощи функции generateEmai
l.
Если случится ошибка, то она распространяется как обычно, от невыполненного обещания к Promise.all
, а затем превращается в исключение, которое можно отловить в блоке catch
.
Ключевые выводы
async
и await
позволяет писать асинхронный код так, что он выглядит и действует как синхронный. Такой код становится гораздо проще читать, писать и судить о нем.
Завершу статью несколькими ключевыми тезисами; помните о них, когда будете работать над вашим следующим асинхронным проектом на TypeScript
.
await
работает только внутри функцииasync
Функция, помеченная ключевым словом
async
, всегда возвращаетPromise
Если возвращаемое значение внутри
async
не возвращаетPromise
, то оно будет обернуто в немедленно разрешаемыйPromise
Как только встретится ключевое слово
await
, выполнение приостанавливается, пока не будет завершеноPromise
await
либо вернет результат от выполненногоPromise
, либо выбросит исключение от отклоненногоPromise