Асинхронный 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.
Что читать дальше
- DOM и события (02-frontend/06-javascript/02-the-dom-and-events) — обработчики, которые запускают эту асинхронную работу, и страница, которую она обновляет по завершении.
- Модули и инструменты (02-frontend/06-javascript/04-modules-and-tooling) — как написанный вами код делится на части и доставляется в браузер.
- Веб-производительность (Core Web Vitals) (02-frontend/26-web-performance-core-web-vitals) — чего длинные задачи и поздние ответы стоят пользователю.