Помните времена, когда весь ваш фронтенд-код превращался в так называемый «ад колбэков»? Вы просили данные с сервера, получали их, потом на основе этих данных запрашивали еще одни, и так далее. Код рос не вниз, а вправо, становясь нечитаемым кошмаром для любого, кто пытался его поддерживать. Промисы (Promises) пришли на помощь, чтобы сделать асинхронное программирование предсказуемым и чистым. Если вы до сих пор путаете .then() с await или боитесь необработанных исключений в асинхронном коде, эта статья - ваш гид.
| Состояние | Описание | Возможные переходы |
|---|---|---|
| Pending | Начальное состояние. Операция еще не завершена. | Может перейти в Fulfilled или Rejected |
| Fulfilled | Операция завершилась успешно. | Финальное состояние (не меняется) |
| Rejected | Операция завершилась ошибкой. | Финальное состояние (не меняется) |
Что такое Промис и зачем он нужен
Давайте разберемся без лишней воды. Промис - это объект, который представляет итог асинхронной операции. Представьте, что вы заказали пиццу. Пока курьер в пути, ваш заказ находится в статусе Pending (ожидание). Когда пицца приехала и она горячая - это Fulfilled (успех). Если курьер потерялся или пицца упала на пол - это Rejected (отказ). Главное отличие промисов от старых колбэков в том, что промис может изменить свое состояние только один раз. Он либо выполнен, либо отклонен. Никаких повторных вызовов функций обратного вызова, которые часто приводили к багам.
Использование промисов решает три главные проблемы:
- Читаемость кода: Цепочки вызовов вместо вложенности.
- Единый механизм обработки ошибок: Один блок
catchдля всей цепочки. - Интероперабельность: Большинство современных API (Fetch, File System, Database drivers) возвращают именно промисы.
Как создать Промис вручную
Обычно вы получаете промис из библиотек или браузерных API. Но иногда нужно обернуть старый синхронный код или сложную логику в промис. Для этого используется конструктор new Promise().
Конструктор принимает функцию с двумя аргументами: resolve и reject. Это функции-колбэки, которые меняют состояние промиса. Вот простой пример имитации загрузки профиля пользователя:
const getUserProfile = (userId) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (userId > 0) {
resolve({ id: userId, name: 'Родион', role: 'Developer' });
} else {
reject(new Error('Некорректный ID пользователя'));
}
}, 1000);
});
};
Здесь мы используем setTimeout, чтобы имитировать задержку сети. Если ID положительный, мы вызываем resolve с данными объекта. Если нет - вызываем reject с объектом ошибки. Обратите внимание: важно передавать в reject именно экземпляр Error. Это поможет вам позже отлавливать ошибки через instanceof Error и видеть стек вызовов.
Цепочки промисов: метод .then()
Сила промисов раскрывается в цепочках. Метод .then() позволяет обрабатывать успешное выполнение промиса. Но вот нюанс, о котором забывают новички: .then() всегда возвращает новый промис. Если внутри обработчика вы просто вернете значение, новый промис будет разрешен этим значением. Если вы вернете другой промис, цепочка будет ждать его завершения.
Рассмотрим типичный сценарий: сначала получаем пользователя, затем его настройки, затем сохраняем лог действий.
getUserProfile(1)
.then(user => {
console.log(`Загружен профиль: ${user.name}`);
return fetchSettings(user.id); // Возвращаем новый промис
})
.then(settings => {
console.log('Настройки загружены:', settings);
return saveLog(user.id, settings.theme);
})
.catch(error => {
console.error('Что-то пошло не так:', error.message);
});
Если ошибка возникнет на любом этапе (в первом запросе, во втором или третьем), она упадет в единственный блок .catch() в конце. Это значительно упрощает жизнь по сравнению с проверкой ошибок после каждого шага в колбэк-стиле.
Async/Await: сахар поверх промисов
В 2017 году в стандарт ECMAScript вошли ключевые слова async и await. Многие думают, что это замена промисам. На самом деле, это просто более удобный синтаксис для работы с ними. Под капотом всё равно работают промисы.
Функция, помеченная как async, всегда возвращает промис. Внутри такой функции можно использовать await, который приостанавливает выполнение функции до разрешения промиса, но не блокирует поток выполнения всего приложения.
async function loadUserData(userId) {
try {
const user = await getUserProfile(userId);
console.log(`Привет, ${user.name}!`);
const settings = await fetchSettings(user.id);
console.log('Тема интерфейса:', settings.theme);
return true;
} catch (error) {
console.error('Ошибка загрузки:', error);
return false;
}
}
Этот код читается почти как обычный синхронный скрипт. Однако есть ловушка. Не используйте await в циклах, если операции независимы друг от друга. Например, если вам нужно загрузить аватарки 5 пользователей, лучше запустить все запросы параллельно, чем ждать каждый по очереди.
Параллельное выполнение: Promise.all и другие помощники
Часто возникает задача выполнить несколько асинхронных операций одновременно. Здесь на помощь приходят статические методы класса Promise. Они критически важны для оптимизации производительности вашего приложения.
| Метод | Условие успеха | Условие отказа | Когда использовать |
|---|---|---|---|
| Promise.all | Все промисы выполнены успешно | Хотя бы один промис отклонен | Когда нужны результаты всех операций (например, данные для страницы) |
| Promise.race | Первый выполненный промис | Первый отклоненный промис | Таймауты запросов, гонка источников данных |
| Promise.allSettled | Все промисы завершены (любой исход) | Никогда (всегда fulfilled) | Когда нужно собрать отчет об ошибках, игнорируя неудачи отдельных частей |
| Promise.any | Первый успешно выполненный промис | Все промисы отклонены | Поиск первого доступного зеркала сервера |
Разберем самый частый кейс - Promise.all. Допустим, нам нужно получить список товаров и список категорий одновременно. Ждать последовательно долго. Запускаем параллельно:
async function initDashboard() {
try {
const [products, categories] = await Promise.all([
fetchProducts(),
fetchCategories()
]);
renderPage(products, categories);
} catch (err) {
showError('Не удалось загрузить данные');
}
}
Если хотя бы один из запросов упадет, вся конструкция перейдет в состояние rejected. Это строгое поведение. Если же вам важно узнать статус каждой отдельной операции, даже если одна из них фейится, используйте Promise.allSettled. Он вернет массив объектов с полями status ('fulfilled' или 'rejected') и value/reason.
Типичные ошибки и лучшие практики
Работа с промисами таит в себе подводные камни, которые могут привести к утечкам памяти или зависанию интерфейса.
- Забывчивость с catch: Никогда не оставляйте промис без обработки ошибок. Необработанный промис может тихо упасть, и вы никогда не узнаете почему. В Node.js это даже приводит к крашу процесса (unhandledRejection).
- Лишние then: Избегайте написания
.then(res => res.json()).then(data => ...). Лучше сразу вернутьres.json()из первого блока, так как он сам вернет промис. - Блокирующий цикл с await: Как уже упоминалось,
for...ofсawaitвнутри работает последовательно. Для параллелизма используйтеmapвместе сPromise.all.
Еще один важный момент - отмена промисов. В классическом JavaScript промис нельзя отменить после создания. Если вы создали промис на запрос, он будет висеть в памяти, пока не завершится, даже если компонент размонтирован. Для решения этой проблемы в современных браузерах используют AbortController в связке с fetch, но сам объект Promise остается неизменным.
Практический чек-лист перед деплоем
Прежде чем отправлять код с асинхронными операциями в продакшен, пробегитесь по этому списку:
- Все ли внешние вызовы (API, БД) обернуты в try/catch или имеют .catch()?
- Нет ли случайных последовательных await там, где можно использовать Promise.all?
- Обрабатываете ли вы случаи, когда промис может зависнуть навсегда (таймауты)?
- Проверено ли поведение при плохом интернете (slow network throttling в DevTools)?
Понимание того, как живут промисы в event loop, отличает джуна от мидла. Помните, что код внутри .then() или после await выполняется не сразу, а попадает в очередь микрозадач. Это значит, что он выполнится быстрее, чем setTimeout(..., 0), но медленнее, чем синхронный код. Эта тонкость важна для тестирования и оптимизации рендеринга.
Чем промис отличается от callback?
Callback - это функция, которую вы передаете другой функции для вызова позже. Проблема в том, что при вложенности асинхронных операций код становится трудно читать («callback hell»). Промис - это объект, представляющий будущее значение. Он гарантирует однократное изменение состояния и позволяет строить читаемые цепочки, а также централизованно обрабатывать ошибки.
Можно ли отменить промис?
Стандартный объект Promise не поддерживает встроенную отмену. После запуска асинхронной операции она будет выполняться независимо от того, слушаете ли вы её результат. Однако вы можете использовать AbortController для отмены сетевых запросов (fetch) или реализовать собственную логику отмены, проверяя флаг внутри функции, возвращающей промис.
Что произойдет, если я забуду добавить await перед промисом?
Вы получите объект Promise вместо ожидаемого значения (данных). Это часто приводит к ошибкам типа «Cannot read property of undefined», потому что вы пытаетесь обратиться к свойствам объекта Promise, а не к данным внутри него. Также выполнение кода продолжится дальше, не дожидаясь завершения асинхронной операции, что нарушит логику приложения.
Какая разница между Promise.all и Promise.allSettled?
Promise.all «fail-fast»: если хоть один промис отклоняется, весь метод немедленно переходит в состояние rejected. Promise.allSettled ждет завершения всех промисов, независимо от их исхода, и всегда возвращается со статусом fulfilled, предоставляя информацию об успехе или ошибке каждого отдельного промиса.
Является ли async/await новым типом данных?
Нет. Async/await - это синтаксический сахар над промисами. Функция, объявленная с async, всегда возвращает промис. Ключевое слово await можно использовать только внутри async-функций (или на верхнем уровне модулей ES6+) и оно ожидает разрешения промиса, делая код визуально синхронным.