Асинхронный JavaScript

Что это такое

Асинхронный JavaScript — механизм, которым язык справляется с работой, завершающейся не сразу: сетевым запросом, таймером, чтением файла. Пока такая работа идёт, страница не замерзает. Вы запускаете её и передаёте функцию, которую нужно выполнить по завершении, а браузер продолжает жить. Промисы и async/await — современная запись этой передачи.

Почему это важно

Одна блокирующая операция в главном потоке — и вся страница становится неотзывчивой: вкладка больше не перерисовывается, клики копятся в очереди, а браузер в конце концов предлагает страницу убить. Поэтому каждое реальное приложение что-то загружает, ждёт и повторяет. Почти каждая ошибка в таком приложении — ошибка порядка: работа завершилась уже после того, как состояние, которое она должна была обновить, исчезло; два запроса устроили гонку; ошибку так и не поймали, потому что отклонению некуда было попасть. Асинхронная модель — не продвинутая тема, а форма кода, который исполняет браузер.

Ключевые идеи

Есть один поток и очередь

JavaScript выполняет по одной вещи за раз. Когда работа вроде сетевого вызова завершается, её колбэк встаёт в очередь и запускается только после того, как текущий стек вызовов опустеет. Поэтому длинный цикл не даёт сработать таймеру, и поэтому же код после await возобновляется позже, а не сразу.

console.log("1");
setTimeout(() => console.log("3"), 0);
console.log("2");        // выводит 1, 2, 3

Промис — это заполнитель с тремя состояниями

Сначала промис находится в состоянии pending, а затем один раз и навсегда переходит либо в fulfilled со значением, либо в rejected с причиной. .then и .catch присоединяют колбэки; из .then возвращается новый промис, на этом и держится цепочка.

fetch("/api/orders")
  .then((response) => {
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  })
  .then((orders) => render(orders))
  .catch((error) => showError(error));

Обратите внимание на проверку: fetch переходит в fulfilled и при 404, и при 200. Сетевой сбой отклоняет промис, а статус ошибки — нет.

async/await — то же самое, но записанное линейно

async-функция всегда возвращает промис, а await приостанавливает её и ждёт, пока ожидаемый промис не завершится; затем работа продолжается со значением либо выбрасывается причина. Оборачивайте в try/catch: необработанное отклонение во многих контекстах проходит молча.

async function loadOrders() {
  try {
    const response = await fetch("/api/orders");
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return await response.json();
  } catch (error) {
    showError(error);
    return [];
  }
}

Независимая работа должна идти параллельно

Если ждать по очереди, страница дождётся суммы длительностей; однако Promise.all ждёт только самую долгую из них. Promise.allSettled отдаёт все результаты, включая неудачные, — обычно это и нужно, когда один сбой не должен отменять остальные.

const [user, orders] = await Promise.all([loadUser(id), loadOrders(id)]);

Отмена и устаревание — ваша забота

Промис отменить нельзя, однако запрос под ним — можно, с помощью AbortController. Когда пользователь набирает новый запрос, отмените предыдущий или просто проигнорируйте его результат: иначе победит самый медленный ответ.

Никогда не оставляйте отклонение необработанным

Каждой цепочке промисов нужен catch, а каждому await — окружающий try. В браузере необработанное отклонение доходит до события unhandledrejection, а в Node может завершить процесс.

Практика

Загрузите три эндпоинта одновременно

Загрузите три независимых ресурса и отрисуйте их вместе — сначала последовательно, затем через Promise.all; замерьте оба варианта.

Готово, когда: параллельный вариант занимает примерно столько же, сколько самый медленный запрос, а не их сумма, и неудачный запрос по-прежнему показывает ошибку, а не пустую страницу.

Отмените устаревший поиск

Постройте поле поиска, которое запускает запрос на каждое нажатие клавиши, и отменяйте предыдущий запрос через AbortController, когда начинается новый.

Готово, когда: при быстром наборе никогда не отрисовываются результаты для более раннего запроса, а панель сети показывает брошенные запросы как отменённые.

Частые ошибки

  • Забыть, что fetch переходит в fulfilled при статусе ошибки — ответ 500 считается успехом, и разбор JSON падает непонятно; исправить: проверяйте response.ok и выбрасывайте ошибку.
  • Ожидание независимых запросов по очереди — страница ждёт сумму длительностей; исправить: запускайте их вместе и ожидайте Promise.all.
  • Оставленный промис без catch — отклонение исчезает, а интерфейс остаётся в состоянии загрузки; исправить: присоединяйте обработчик к каждой цепочке и оборачивайте каждый await.
  • Чтение состояния после await в предположении, что оно не изменилось — пока запрос был в полёте, значение могли заменить; исправить: захватите нужное до ожидания или защититесь отменой.
  • Использование await внутри обычного forEach — колбэки не ожидаются, и цикл завершается первым; исправить: используйте for...of или Promise.all поверх map.

Что читать дальше