Асинхронное программирование в JavaScript: от промисов до async/await
Для кого эта статья:
- начинающие и средние разработчики JavaScript
- студенты курсов по веб-разработке
профессионалы, ищущие углубленные знания в асинхронном программировании
Асинхронное программирование — ключевой навык, без которого невозможно построить эффективные JavaScript-приложения. Каждый разработчик, работающий с API, файловой системой или базами данных, неизбежно сталкивается с необходимостью управлять асинхронными операциями. Ад колбэков, промис-цепочки и элегантный async/await — эволюция асинхронности в JavaScript прошла длинный путь. В этом руководстве мы разберем от основ до продвинутых техник, как организовать асинхронный код так, чтобы он оставался читаемым, поддерживаемым и эффективным. 🚀
Разбираетесь с промисами и async/await, но никак не можете уложить это в голове? На курсе Обучение веб-разработке от Skypro вы не только освоите асинхронное программирование в JavaScript от основ до продвинутых техник, но и закрепите знания на практических проектах под руководством опытных менторов. Наши студенты усваивают концепции на 80% быстрее благодаря методологии, сочетающей теорию с реальными задачами из индустрии.
Основы асинхронного программирования в JavaScript
JavaScript изначально создавался как однопоточный язык программирования. Это означает, что он выполняет код последовательно, строка за строкой. Однако для веб-приложений такой подход неэффективен — например, при загрузке данных с сервера пользовательский интерфейс не должен "зависать".
Асинхронное программирование решает эту проблему, позволяя JavaScript выполнять длительные операции без блокировки основного потока выполнения. Чтобы понять, почему это так важно, рассмотрим простой пример:
// Синхронный код
console.log("Начало");
const data = fetchDataSync(); // Представим, что эта функция занимает 3 секунды
console.log("Данные:", data);
console.log("Конец");
В синхронном варианте "Конец" появится только после получения данных. А что если запрос занимает 10 секунд? Пользователь увидит "замершую" страницу.
Вот как выглядит тот же код в асинхронном стиле с использованием колбэков:
// Асинхронный код с колбэками
console.log("Начало");
fetchDataAsync(function(data) {
console.log("Данные:", data);
});
console.log("Конец");
// Вывод: "Начало", "Конец", "Данные: [результат]"
В асинхронной версии "Конец" выводится сразу, не дожидаясь завершения запроса данных. Это основополагающий принцип асинхронного программирования в JavaScript.
| Подход | Преимущества | Недостатки |
|---|---|---|
| Колбэки | Простота, поддержка в старых браузерах | Колбэк-ад (callback hell), сложность обработки ошибок |
| Промисы | Цепочки вызовов, централизованная обработка ошибок | Больше кода, чем с async/await |
| Async/await | Читаемость, похожесть на синхронный код | Требует ES2017 или выше, возможные неявные проблемы |
Александр Петров, Senior Frontend Developer
Помню свой первый крупный проект на JavaScript — приложение для аналитики данных. Мы использовали колбэки для всех асинхронных операций, и код быстро превратился в непроходимые дебри. Особенно сложно было при цепочках запросов к API, где каждый последующий зависел от результатов предыдущего.
Когда мы перешли на промисы, код стал более структурированным, но настоящий прорыв произошел после внедрения async/await. Время разработки новых фич сократилось на 40%, а количество багов, связанных с асинхронностью, уменьшилось почти втрое. Наиболее впечатляющий результат — мы смогли оптимизировать нагрузку на сервер, распараллелив запросы с помощью Promise.all, что ускорило загрузку данных на 60%.

Промисы в JavaScript: создание и управление
Промис (Promise) — объект, представляющий результат асинхронной операции. Он может находиться в одном из трёх состояний:
- Ожидание (pending): начальное состояние, операция не завершена
- Выполнено (fulfilled): операция завершена успешно
- Отклонено (rejected): операция завершена с ошибкой
Создание промиса выглядит так:
const myPromise = new Promise((resolve, reject) => {
// Асинхронная операция
const success = true;
if (success) {
resolve("Операция выполнена успешно");
} else {
reject("Произошла ошибка");
}
});
Для работы с результатом промиса используются методы .then() и .catch():
myPromise
.then(result => {
console.log("Успех:", result);
return "Новое значение"; // Это значение передастся в следующий then
})
.then(newResult => {
console.log("Цепочка:", newResult);
})
.catch(error => {
console.error("Ошибка:", error);
});
Один из мощных приемов работы с промисами — возможность выполнять несколько асинхронных операций параллельно с помощью статических методов:
- Promise.all() — ожидает завершения всех промисов (или первой ошибки)
- Promise.race() — возвращает результат первого завершившегося промиса
- Promise.allSettled() — ожидает завершения всех промисов (с 2020 года)
- Promise.any() — возвращает результат первого успешно завершившегося промиса (с 2021 года)
Пример использования Promise.all() для параллельного запуска нескольких запросов:
const fetchUserData = fetch('/api/user').then(r => r.json());
const fetchUserPosts = fetch('/api/posts').then(r => r.json());
const fetchUserComments = fetch('/api/comments').then(r => r.json());
Promise.all([fetchUserData, fetchUserPosts, fetchUserComments])
.then(([userData, posts, comments]) => {
console.log("Все данные получены:", userData, posts, comments);
})
.catch(error => {
console.error("Ошибка при получении данных:", error);
});
Async/await: синтаксический сахар для промисов
Async/await — синтаксическая конструкция, появившаяся в ES2017, которая делает асинхронный код похожим на синхронный. Под капотом она все равно использует промисы, но делает код более читаемым и простым для восприятия. 🧠
Ключевое слово async объявляет функцию как асинхронную, которая автоматически возвращает промис:
async function fetchData() {
return "Данные получены";
}
// Эквивалентно
function fetchDataPromise() {
return Promise.resolve("Данные получены");
}
Ключевое слово await приостанавливает выполнение функции до тех пор, пока промис не вернёт результат:
async function getUser() {
const response = await fetch('/api/user');
const userData = await response.json();
return userData;
}
// Использование
getUser()
.then(user => console.log(user))
.catch(error => console.error(error));
// Или в другой async-функции
async function displayUser() {
try {
const user = await getUser();
console.log(user);
} catch (error) {
console.error(error);
}
}
Сравним промисы и async/await на реальном примере получения и обработки данных:
// С использованием промисов
function processDataWithPromises() {
return fetchData()
.then(data => {
return processFirstStep(data);
})
.then(firstResult => {
return processSecondStep(firstResult);
})
.then(finalResult => {
return formatResult(finalResult);
});
}
// С использованием async/await
async function processDataWithAsyncAwait() {
const data = await fetchData();
const firstResult = await processFirstStep(data);
const finalResult = await processSecondStep(firstResult);
return formatResult(finalResult);
}
Несмотря на свою элегантность, async/await имеет некоторые особенности, которые важно учитывать:
- Использовать await можно только внутри async-функций
- Последовательный await может замедлить код, если операции могли бы выполняться параллельно
- Необходимо правильно обрабатывать ошибки с помощью try/catch
Марина Соколова, Team Lead в проекте финтех-стартапа
Мы занимались разработкой платежного шлюза, где критически важна надежность операций. Исторически наш код был построен на промисах, и это создавало трудности для новых членов команды, которым приходилось разбираться в сложных цепочках then-catch.
Переход на async/await стал поворотным моментом. Мы переписали ключевые компоненты, и код стал значительно понятнее. Самое интересное произошло, когда мы рефакторили модуль для обработки транзакций — количество строк кода уменьшилось на 30%, а время на онбординг новых разработчиков сократилось вдвое.
Однако мы столкнулись с неожиданной проблемой: некоторые API-запросы, которые раньше выполнялись параллельно через Promise.all, теперь выполнялись последовательно, что увеличило время обработки платежей. Пришлось балансировать между читаемостью кода и производительностью, внедряя Promise.all внутри async-функций для критичных операций.
Обработка ошибок в промисах и async/await функциях
Корректная обработка ошибок в асинхронном коде — одна из ключевых задач разработчика. В зависимости от выбранного подхода (промисы или async/await), техники обработки ошибок различаются.
В промисах ошибки обрабатываются через метод .catch():
fetchData()
.then(data => processData(data))
.then(result => displayResult(result))
.catch(error => {
console.error("Произошла ошибка:", error);
showErrorMessage(error);
});
При использовании async/await применяется традиционный блок try/catch:
async function fetchAndProcess() {
try {
const data = await fetchData();
const result = await processData(data);
displayResult(result);
} catch (error) {
console.error("Произошла ошибка:", error);
showErrorMessage(error);
}
}
Важно помнить о "проглатывании исключений" — распространенной ошибке при работе с промисами:
// Неправильно: ошибка будет проигнорирована
somePromise().catch(console.log);
// Правильно: перебрасываем ошибку после логирования
somePromise()
.catch(error => {
console.error(error);
throw error; // Пробрасываем ошибку дальше
});
Для создания пользовательских ошибок в асинхронном коде рекомендуется расширять класс Error:
class ApiError extends Error {
constructor(message, statusCode) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
}
}
async function fetchUserData(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new ApiError(`Не удалось получить данные пользователя`, response.status);
}
return await response.json();
} catch (error) {
if (error instanceof ApiError) {
// Обработка специфической ошибки API
console.error(`Ошибка API (${error.statusCode}): ${error.message}`);
} else {
// Обработка других ошибок
console.error(`Непредвиденная ошибка: ${error}`);
}
throw error; // Пробрасываем ошибку дальше
}
}
| Сценарий | Промисы | Async/Await |
|---|---|---|
| Базовая обработка ошибок | .catch(error => { ... }) | try { ... } catch (error) { ... } |
| Повторные попытки | Сложная цепочка промисов с рекурсией | Циклы do-while с try-catch внутри |
| Параллельные операции | Promise.all().catch() | try { await Promise.all() } catch { ... } |
| Обработка в рамках компонента | Сложно локализовать источник ошибки | Четко видно, где произошла ошибка |
Особое внимание следует уделить обработке ошибок при работе с Promise.all():
// При первой же ошибке весь Promise.all завершится с исключением
try {
const results = await Promise.all([
fetch('/api/data1').then(r => r.json()),
fetch('/api/data2').then(r => r.json()),
fetch('/api/data3').then(r => r.json())
]);
} catch (error) {
// Мы не знаем, какой именно запрос завершился с ошибкой
console.error("Один из запросов завершился с ошибкой:", error);
}
// Лучше использовать Promise.allSettled() для получения статуса каждого промиса
const promises = [
fetch('/api/data1').then(r => r.json()),
fetch('/api/data2').then(r => r.json()),
fetch('/api/data3').then(r => r.json())
];
const results = await Promise.allSettled(promises);
// Обрабатываем результаты
results.forEach((result, index) => {
if (result.status === "fulfilled") {
console.log(`Запрос ${index} успешен:`, result.value);
} else {
console.error(`Запрос ${index} завершился с ошибкой:`, result.reason);
}
});
Продвинутые техники работы с асинхронным кодом
После освоения базовых принципов работы с промисами и async/await, пора перейти к продвинутым техникам, которые помогут оптимизировать и улучшить асинхронный код. 🔥
1. Параллельное выполнение с контролем порядка результатов
Используя Promise.all(), мы можем запускать операции параллельно, сохраняя порядок результатов:
async function fetchMultipleApis() {
const urls = [
'https://api.example.com/endpoint1',
'https://api.example.com/endpoint2',
'https://api.example.com/endpoint3'
];
// Запускаем запросы параллельно, но получаем результаты в том же порядке
const results = await Promise.all(
urls.map(url => fetch(url).then(r => r.json()))
);
// results[0] соответствует endpoint1, results[1] — endpoint2 и т.д.
return results;
}
2. Ограничение скорости (rate limiting) для асинхронных операций
Часто требуется ограничить количество одновременно выполняемых асинхронных операций, например, при запросах к API с лимитами:
async function processWithRateLimiting(items, concurrencyLimit = 3) {
const results = [];
const running = new Set();
for (const item of items) {
const promise = processItem(item)
.then(result => {
results.push(result);
running.delete(promise);
});
running.add(promise);
if (running.size >= concurrencyLimit) {
// Ждем, пока хотя бы один промис завершится
await Promise.race([...running]);
}
}
// Дожидаемся завершения всех оставшихся промисов
await Promise.all([...running]);
return results;
}
3. Отмена асинхронных операций с AbortController
Начиная с современных браузеров, можно отменять fetch-запросы с помощью AbortController:
async function fetchWithTimeout(url, timeoutMs = 5000) {
const controller = new AbortController();
const { signal } = controller;
// Устанавливаем таймаут для отмены запроса
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal });
clearTimeout(timeout);
return await response.json();
} catch (error) {
clearTimeout(timeout);
if (error.name === 'AbortError') {
throw new Error(`Запрос к ${url} был отменен по таймауту (${timeoutMs}мс)`);
}
throw error;
}
}
4. Повторные попытки с экспоненциальной задержкой
Реализация механизма повторных запросов с увеличивающимися интервалами:
async function fetchWithRetry(url, options = {}, maxRetries = 3, baseDelay = 1000) {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fetch(url, options).then(r => r.json());
} catch (error) {
console.warn(`Попытка ${attempt + 1} не удалась:`, error);
lastError = error;
// Экспоненциальная задержка: 1s, 2s, 4s, ...
const delay = baseDelay * Math.pow(2, attempt);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error(`Все ${maxRetries} попыток завершились неудачно. Последняя ошибка: ${lastError}`);
}
5. Кэширование промисов
Оптимизация повторяющихся асинхронных запросов через кэширование промисов:
const promiseCache = new Map();
function cachedFetch(url) {
if (promiseCache.has(url)) {
return promiseCache.get(url);
}
const promise = fetch(url)
.then(response => response.json())
.catch(error => {
// Удаляем из кэша в случае ошибки
promiseCache.delete(url);
throw error;
});
promiseCache.set(url, promise);
return promise;
}
6. Асинхронные генераторы и итераторы
Для обработки больших наборов данных можно использовать асинхронные генераторы:
async function* fetchPaginatedData(baseUrl, pageSize = 100) {
let page = 1;
let hasMore = true;
while (hasMore) {
const url = `${baseUrl}?page=${page}&limit=${pageSize}`;
const data = await fetch(url).then(r => r.json());
if (data.items.length === 0) {
hasMore = false;
} else {
yield* data.items;
page++;
}
}
}
// Использование
async function processAllItems() {
const generator = fetchPaginatedData('/api/items');
for await (const item of generator) {
await processItem(item);
}
console.log('Все элементы обработаны');
}
Внедрение этих продвинутых техник в ваш арсенал поможет создавать более надежный, эффективный и поддерживаемый асинхронный код. Эти паттерны особенно полезны при разработке сложных приложений с высокими требованиями к производительности и надежности.
Асинхронное программирование в JavaScript прошло значительную эволюцию — от колбэков через промисы к элегантному async/await. Каждый подход имеет свои преимущества и применим в разных ситуациях. Промисы предоставляют мощный инструментарий для организации и контроля параллельных операций, а async/await делает код более интуитивно понятным и читаемым. Независимо от выбранного подхода, глубокое понимание асинхронности в JavaScript — это навык, который кардинально меняет качество разрабатываемых приложений и открывает новые горизонты в оптимизации производительности.