Асинхронность – это способ координации поведения программы на протяжении какого-то временного отрезка. Разбираем, как в синхронном JavaScript вынести операции за рамки единого потока, чтобы не блокировать действие кода после тяжёлых операций.
JavaScript – это однопоточный язык программирования. JS исполняет одну строку кода в одну единицу времени и не предполагает мультитаскинга. Это отличает JS от многопоточного Java, например, где можно создавать отдельные потоки и переносить новый код в отдельно созданный поток.
Поскольку все операции проходят в одном потоке, ряд операций можно сделать асинхронными и вынести за рамки единого потока, чтобы не блокировать дальнейшее исполнение кода. Для этого могут использоваться коллбэки, промисы и асинхронные функции, но в современном JavaScript на практике используются только промисы и асинхронные функции.
Рантайм JS в браузере: event loop
Прежде чем разбирать структуру промисов, освежим в памяти процесс выполнения JS-кода в браузере. Чтобы код выполнялся, необходимо наличие ряда компонентов (движок, Web API, очереди и цикл событий или event loop). Рантайм – это по своей сути контейнер, который содержит все эти компоненты.

Web API хранит в себе асинхронные операции и не является частью движка JS, но предоставляет окружение, набор API, предоставляемых движку JS для его взаимодействия с вебом. На схеме для примера изображены 4 сегмента API, но на деле их может быть неограниченное множество.
Очередь состоит из коллбэков – операций, которые были отложены по времени ввиду асинхронного поведения JS.
Движок – сердце JS, без которого исполнение кода невозможно.
Куча (heap) содержит в себе ссылочные элементы. На взаимодействие с ними тратится больше ресурсов, чем на те, что в стеке, в котором содержатся статичные элементы. Стек помимо хранения примитивных данных выполняет поступающие к нему инструкции. Действует по принципу last in – first out. То есть когда приходит какой-то элемент, он накладывается сверху и будет исполнен в самую первую очередь.
Цикл событий (event loop) играет роль дирижёра, который делает корректным выполнение программы.
Когда стек пустой, ивент луп прокручивается, смотрит в очередь, обнаруживает, есть ли там колбэк, который можно было бы прокинуть в пустой стек. Если есть, то делает это.
Микротаски и макротаски
Существуют две очереди асинхронных операций: очереди микро- и макротасок. Микротаски имеют более высокий приоритет исполнения. Поэтому когда event loop прокручивается, ему не важно, сколько в очереди макротасок – он возьмёт микротаску, даже если она в очереди единственная, а макротасок 500.
Можно сказать, что у JS нет никакого концепта времени, все асинхронные операции происходят вне движка. Call stack слепо исполняет те инструкции, которые ему передаёт event loop, координирующий работу всей программы.
Ад колбеков (callback hell)
Асинхронности в JS-коде можно добиться, используя колбеки (обратные вызовы). Это такие функции-соглашения, которые возвращают результат не сразу, а спустя какое-то время. В колбеки вкладывается код, который должен выполняться после завершения определённой операции – например, загрузки какого-нибудь изображения. То есть пользователь сразу увидит, например, текстовый контент на странице, а не будет ждать его появления только после прогрузки изображения, которое расположено выше.
Для обеспечения возможности общения клиента с сервером используются AJAX (от англ. Asynchronous JavaScript and XML) запросы. XML в этой связке сейчас уже нет – в ходу JSON-файлы, но название AJAX закрепилось и осталось неизменным.
Ад колбэков можно узнать по вложенной структуре в коде. Получается что-то вроде «пирамидки». Такой код сложно читать и поддерживать. И чем пирамидальная структура глубже, тем сложнее.
Чтобы сделать структуру запросов плоской и упростить ее понимание, была внедрена конструкция промисов.

Промисы: понятие и синтаксис
Промисы помогают работать без ада колбэков. Сам по себе промис – это особый объект, используемый в качестве плейсхолдера для будущего значения завершенной асинхронной операции. Промис (англ. promise – обещать) как бы «обещает» создать это значение, которое на настоящий момент не установлено.
Внутри промиса находится исполнительская функция, которая берёт в себя 2 колбэка. Если функция выполняется успешно, то отрабатывает колбэк resolve, который принимает в себя получившийся в результате аргумент. На примере это строка «Все прошло отлично». Если же промис отклоняется и возникает ошибка, получаем reject. На примере это строчка «Что-то пошло не так». Аргументами могуты быть строка, число функция, объект.
{
const promise = new Promise((resolve, reject) => {
if(allWentWell) {
resolve('Все прошло отлично!');
} else {
reject('Что-то пошло не так');
}
});
Вот создали мы промис, и что дальше? Нужно настроить сценарии его работы. Чтобы получить его результат и взаимодействовать с ним, используем метод then. На случай ошибки можем использовать для отработки функции метод catch. А еще применим блок finally. Если представить, что промис – это вечеринка, то пройти она может хорошо либо плохо. Однако независимо от того, как она пройдёт, после неё нужно прибраться. Finally – это такой «уборщик» – он знает, что вечеринка закончилась – успешно или нет – он приберётся. Внутри у него нет аргумента, и вне зависимости от успешности операции он нам сообщит, что промис завершён.
{
new Promise((resolve, reject) => {
setTimeout(() => resolve("value"), 2000);
})
.finally(() => alert("Промис завершен")) // finally отработает первым
.then(result => alert(result)); // выводится "value"
{
new Promise((resolve, reject) => {
throw new Error("error");
})
.finally(() => alert("Промис завершен")) // finally отработает первым
.catch(err => alert(err)); // выводится ошибка
Разбираем на примере
{
function loadScript(src) {
return new Promise(function(resolve, reject) {
let script = document.createElement('script');
script.src = src;
script.onload = () => resolve(script);
script.onerror = () => reject(new Error(`Ошибка загрузки скрипта ${src}`));
document.head.append(script);
});
}
{
let promise = loadScript("https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.js");
promise.then(
script => alert(`${script.src} загружен!`),
error => alert(`Ошибка: ${error.message}`)
);
promise.then(script => alert('Ещё один обработчик...'));
На верхнем примере функция loadScript возвращает промис, который добавляет какой-то скрипт на сайт. Мы здесь предусматриваем 2 сценария, поэтому прописываем два колбека: resolve и reject.
На втором изображении видим, как это выглядит в коде. Мы вызываем промис и применяем блок then. В этом случае then может принимать не только успешный результат, но и второй аргумент – ошибку. Здесь можем обойтись без catch – это слегка перегружает код, но допустимо. К промису метод then можно применять неограниченное число раз, и каждый раз метод будет отрабатывать с одним и тем же результатом промиса.
Возможные состояния промиса
Когда промис только создан, он находится в состоянии pending. Далее он устанавливается в код (settled), после чего выполняется функция. Из settled в другое состояние промис может перейти лишь единожды. Все попытки изменить состояние промиса после первого изменения будут проигнорированы.
Промисы: цепочка
Чтобы объединить несколько промисов в цепочку, нужно вызвать первый промис, а потом за счет использования then вызывать последующие промисы. Если первый вложенный в цепочку промис отработает успешно и вернет результат, то следующий за ним промис, вызванный через then будет работать с данными из предыдущего промиса. И так по цепочке.
{
const promise1 = new Promise((resolve, reject) => {
resolve('Promise1 выполнен');
});
const promise2 = new Promise((resolve, reject) => {
resolve('Promise2 выполнен');
});
const promise3 = new Promise((resolve, reject) => {
reject('Promise3 отклонен');
});
promise1
.then((data) => { //в data - результат выполнения Promise1
console.log(data); // Promise1 выполнен
return promise2;
})
.then((data) => { //в data - результат выполнения Promise2
console.log(data); // Promise2 выполнен
return promise3;
})
.then((data) => { //в data - результат выполнения Promise3
console.log(data);
})
.catch((error) => {
console.log(error); // Promise3 отклонен
});
Таким образом, каждый следующий после первого промис в цепочке будет работать со значением того промиса, который был в цепочке перед ним.
В теории и методы then можно вложить друг в друга, и такой код будет работать, но делать так не стоит.
Перехват ошибок
Если в ходе выполнения операции возникает ошибка, она отправляется в ближайший обработчик onRejected. Его можно поставить через второй аргумент .then(..., onRejected). Второй способ - более читабельный – через использование .catch(onRejected).
{
function getFromServer(url) {
return new Promise(function (resolve, reject) {
let response
/*...*/
resolve(response)
})
}
getFromServer('/api/users/1')
.then((user) => getFromServer(`/api/photos/${user.id}/`))
.then((photo) => getFromServer(`/api/crop/${photo.id}/`))
.then((response) => console.log(response))
.catch((error) => console.error(error))
Благодаря перехвату ошибок, если что-то пошло не так, то программа не упадёт, а управление перейдёт к последней строчке с catch(), причём независимо от того, в каком из запросов ошибка появится.
Функции Async/Await
Альтернатива коду, который используют промисы – async-функции. По сути это те же промисы, но они позволяют организовать работу с асинхронным кодом в синхронном стиле. Используя конструкцию async/await можно полностью избежать использования цепочек промисов.
Если коротко, то асинхронная функция – это функция, которая возвращает промис. Если мы сравним функцию слева (изображение выше), которая нам явно возвращает промис, и функцию справа, то делают они одно и то же. За счет того, что во второй функции есть ключевое слово async, она становится асинхронной.
Для случая слева, чтобы вывести hello в консоль, нам нужно будет написать вот какой код:
{
function fn() {
return Promise.resolve('hello');
}
fn().then(console.log);
// hello
То есть, чтобы промис отработал, используем метод then.
Для формулы справа, чтобы вывести в консоль hello, нужно написать вот что:
{
async function fn() {
return 'hello';
}
fn().then(console.log)
// hello
Async/Await: синтаксис
{
async function f() {
let promise = new Promise((resolve, reject) => {
setTimeout(() => resolve("готово"), 1000)
});
let result = await promise; // будет ждать, пока промис не выполнится (*)
alert(result); // "готово!"
}
f();
На примере выше появляется ключевое слово await. Оно находится внутри асинхронной функции. Await пишется перед любой основанной на промисе функцией. Когда она будет выполняться, на этой строчке парсер остановится и дождется, пока промис вернет результат. Результат запишется в переменную result, с которым затем можно будет работать.

На примерах выше показано сравнение синтаксиса в цепочке промисов и async/await функциях. По сути это один и тот же код, просто написан он по-разному. Используя async/await, избавляемся от нагромождения then. Это получается за счет последовательности промисов, которые вернут результат через await. Ошибки в исполнении операций здесь также можем отловить через catch.
Параллельное выполнение промисов
У промисов есть отдельные методы, позволяющие работать с параллельным выполнением и асинхронными запросами – это так называемые комбинаторы промисов. Всего существует 4 комбинатора промисов: Promise.all, Promise.any, Promise.allSettled, Promise.race.
Все эти комбинаторы используются одинаково с точки зрения их синтаксиса: принимают в себя массив промисов, но отличаются друг от друга форматом возвращаемых в .then данных.

В случае с комбинаторами Promise.all и Promise.allSettled возвращаемым значением будет массив значений, полученных по результатам обработки промиса.

При этом Promise.all отклоняется целиком, если был отклонен хотя бы один из переданных ему в качестве аргумента промисов.
Promise.all следует использовать, если для корректной работы программы нужны все успешно полученные значения разрешенных промисов, но при этом очередность их получения для логики программы не важна - важен лишь успешный результат каждого из них.
Promise.allSettled разрешается вне зависимости от того, был ли отклонен какой-либо из переданных ему промисов, и возвращает массив объектов, каждый из которых включает в себя, помимо самого значения обработанного промиса, также и статус обработки соответствующего промиса (‘fulfilled’ или ‘rejected’).

Promise.allSettled нужно использовать в тех случаях, когда для корректной работы программы не требуются все результаты каждого из промисов - достаточно лишь части из них.
При использовании Promise.any и Promise.race результатом всегда будет значение лишь одного из переданных в качестве аргумента промисов. Отличие между этими двумя комбинаторами в том, что Promise.any будет ждать первого успешно выполненного промиса и отклонится в том случае, если были отклонены все переданные ему промисы.

Promise.any целесообразно использовать, когда нас интересует лишь значение самого быстрого разрешенного промиса.
Promise.race не ждет именно успешно выполненного промиса – ему важен лишь тот, который был обработан быстрее всех остальных, вне зависимости от того, был ли результат обработки успешным.

Promise.race используют, когда для программы неважно, был ли самый быстрый промис разрешен или отклонен - важен сам факт завершения обработки самого быстрого из них.
Разница между комбинаторами промисов и множественными последовательными await/промисами в скорости запроса данных с сервера. Комбинаторы делают возможным параллельное выполнение запросов, а не последовательное, поэтому операции выполняются в разы быстрее.
Top-level await
Раньше ключевое слово await можно было использовать только в async функциях, что иногда требовало прибегать к использованию конструкции IIFE (immediately invoked function expression).
В стандарт ES2022 был включен верхнеуровневый await (top-level await), который доступен в скриптах типа module.
Когда выбирать синхронное взаимодействие, а когда асинхронное?
Синхронный код используется, когда нужно обеспечить выполнение кода в строго заданном порядке. Асинхронный – в тех случаях, когда код может занять продолжительное время прежде чем закончить свое выполнение, и при этом это его выполнение не должно блокировать исполнение другого кода.