Глубокое погружение в работу промисов в JavaScript
Как работают промисы в JavaScript: жизненный цикл, внутреннее устройство, примеры, история возникновения и практические советы от разработчиков.
2К открытий8К показов
Промисы ежедневно встречаются в работе, но их внутренняя механика часто остаётся загадкой даже для опытных разработчиков.
Поэтому, в статье мы разберём:
- Что стоит за изобретением промиса;
- Как устроен промис «под капотом»;
- Как устроен жизненный цикл промиса;
- Как промис выполняется с точки зрения событийного цикла.
Опирались на перевод статьи Promise Execution и комментарии экспертов. Статья будет полезна как новичкам, которые только знакомятся с асинхронностью, так и сеньорам, которые уже не знают, какими вопросами можно замучить кандидата на собеседовании.
Как появились промисы?
До появления промисов результаты асинхронного кода обрабатывались при помощи функций-коллбэков. При большой вложенности асинхронных вызовов использование коллбеков приводило к проблемам:
- Усложняется обработка ошибок — каждый вложенный уровень требует отдельную обработку;
- Усложняется композиция кода — если в коллбэке обрабатывается несколько параллельных и последовательных запросов, код превращается в лабиринт вложенных коллбеков;
- Снижается читабельность — код начинает жёстко уходить влево и при форматировании становится нечитаемым (pyramid of doom).
Эти проблемы получили общее название, которое вы точно слышали — Callback Hell.
Александр Коротаев, фронтенд-разработчик, автор тг-канала «Трудно быть Коротаевым»:
Проблема Callback Hell во времена появления промисов почти всегда встречалась только в мире Node.js разработки, так как best-practices подразумевали всегда использовать только асинхронные методы для парсинга и запроса данных: распарсить данные от пользователя, получить его авторизацию, сходить в базу данных, в зависимости от результата сходить туда ещё пару раз… всё это рождало ту самую лесенку коллбеков.
Все API были написаны в таком стиле.
Node.js был довольно молодым и сырым, но, даже в таком виде, — крайне популярен для бекенд-разработки.
Как промисы решили проблему коллбэков?
В решении проблемы помогла теория фьючерсов из общего курса информатики.
Фьючерс — это объект-плейсхолдер для результата асинхронного вызова, который может быть либо ожидаемым (ещё не вычислен), либо уже разрешённым (вычислен успешно или с ошибкой).
Отличие от промиса в том, что фьючерс блокирует свой поток до получения данных. В Java такой подход не вызывает проблем благодаря встроенной поддержке многопоточности, в то время как JavaScript имеет только один поток.
Поэтому идею фьючерса переработали в идею промиса, в которой ожидание выполнения коллбека добавляется в отдельную очередь, что позволяет не блокировать основной поток.
Анастасия Егорова, фронтенд-разработчик, автор тг-канала «Код и кофе»:
Добавлю момент из новейшей истории промисов: в спецификации ES2025, которая была утверждена совсем недавно, появилось новое элегантное решение, связанное с промисами.
Технический комитет добавил в спецификацию Promise.try() — статический метод, который принимает колбек любого вида и оборачивает его в промис, делая обработку синхронного и асинхронного кода чище и единобразней.
С историей возникновения промиса мы разобрались, теперь разберёмся с его внутренним строением.
Конструктор промиса
Промис создаётся с помощью конструктора new Promise(), который принимает executer (функцию-исполнитель) с аргументами-функциями resolve и reject:
resolve(value)— переводит промис в состояние fulfilled (успех) с результатом value.reject(error)— переводит промис в состояние rejected (ошибка) с причиной error.
Функция executer имеет несколько особенностей:
executer вызывается сразу при создании промиса (до присваивания переменной):
Аргументы resolve и reject можно вызвать только один раз — последующие вызовы будут игнорироваться:
Необработанные ошибки в executer автоматически вызывают reject:
Промис не может быть создан с аргументом undefined:
Помимо самого конструктора у Promise есть ещё и статические методы:
— Promise.resolve(value): создаёт уже fullfilled промис, полезно, когда нужно вернуть из функции промис.
— Promise.reject(value): то же самое, но в состоянии rejected.
— const { promise, resolve, reject } = Promise.withResolvers(): тоже самое, как и конструктор, только возвращает переменные для управления промисом, чтобы использовать их во внешней среде, например, передать куда-то как значение.
Жизненный цикл промиса
После выполнения new Promise() и executor в Call Stack, в памяти создаётся Promise Object.
Call Stack — стек вызовов в JavaScript, который отслеживает текущие выполняемые функции.
Создание Promise Object
Хотя промис — объект, его внутренняя реализация больше похожа на класс: он содержит публичные методы и имеет открытые/закрытые свойства.
Открытые свойства
Свойство [[PromiseState]] хранит одно из трёх состояний промиса:
- pending — ожидание (начальное состояние);
- fulfilled — успешное выполнение (после resolve);
- rejected — отклонено (после reject).
В свойстве [[PromiseResult]] хранится значение, переданное в resolve или reject:
- При fulfilled — значение value, переданное в resolve;
- При rejected — ошибка, переданная в reject;
- При pending — undefined.
Эти свойства можно увидеть в консоли, но их нельзя как-то проверить в коде во время исполнения. Чтобы отобразить состояние промисов где-то в UI, используйте внешние переменные / стейт-менеджеры.
Однажды на собеседовании меня спросили, как создать бесконечный промис. Это довольно синтетический вопрос, однако попробуйте дать ответ на него самостоятельно, сейчас у вас уже есть вся необходимая информация.
Правильный ответ: new Promise(() => {})
Такой промис никогда не завершится, никто не сможет ни зарезолвить, ни отклонить его.
Скрытые свойства
В скрытом свойстве [[PromiseIsHandled]] хранится флаг, который указывает на наличие обработчиков then и catch:
- true — у промиса есть обработчики;
- false — у промиса нет обработчиков
В скрытом свойстве [[PromiseFulfillReactions]] хранится очередь коллбеков успешного выполнения, тех, что передаются в .then(коллбек). Содержимое очереди вызывается при переходе промиса в состояние fulfilled.
Важно: обработчики из [[PromiseFulfillReactions]] выполняются асинхронно.
В скрытом свойстве [[PromiseRejectReactions]] хранится очередь коллбеков обработки ошибок, тех, что передаются в .then(null, коллбек) или .catch(коллбек). Содержимое очереди вызывается при переходе промиса в состояние rejected.
Создание Promise Capability Record
После создания Promise Object образуется механизм, описанный в спецификации — Promise Capability Record. Он связывает объект промиса с функциями resolve и reject.
Этот механизм содержит три поля:
- Поле
[[Promise]]содержит ссылку на созданный промис; - Поле
[[Resolve]]содержит хэндлер, который переводит промис в состояние fulfilled с переданным значением; - Поле
[[Reject]]содержит хэндлер, который переводит промис в состояние rejected и указывает причину.
При помощи Promise Capability Record коллбеки resolve и reject изменяют внутренние состояния промиса:
Пример изменения состояния промиса при помощи resolve:
Пример изменения состояния промиса при помощи reject:
Как при этом меняются открытые свойства [[PromiseState]] и [[PromiseResult]]?
При создании промиса:
При вызове resolve("Done!"):
Здесь всё просто — методами, изменяющими внутреннее состояние класса, сейчас никого не удивишь!
Теперь рассмотрим, как промис работает с методом then:
Обработка коллбэков в then
Для обработки коллбеков в then промис использует две очереди, которые уже упоминали ранее:
[[PromiseFulfillReactions]][[PromiseRejectReactions]]
При вызове resolve коллбеки, отправленные в [[PromiseFulfillReactions]], будут по очереди отправляться в Microtask Queue получая значение из resolve (result):
Именно в этом месте обрабатывается асинхронная часть промиса — её мы разберём позже.
Как только коллбеки из очереди будут выполнены, а финальное значение высчитано — промис возвратит содержимое [[PromiseResult]].
Визуализация жизненного цикла промиса с использованием асинхронности
До этого момента мы вызывали resolve или reject напрямую, внутри executer, без какой-либо асинхронности.
Давайте исправим — попробуем сделать resolve значения, которое придёт с задержкой:
Шаг за шагом рассмотрим, что будет происходить в это время под капотом:
- Конструктор
new Promiseпопадает в Call Stack, после чего инициируются Promise Object и Promise Capability Record;
- Выполняется executor, затем в Call Stack попадает
setTimeout; - В Web API отправляется отложенный коллбек из
setTimeout; - Из Call Stack удаляется конструктор
new PromiseиsetTimeout; - В Call Stack отправляется then, после чего создаётся Promise Reaction Record, в которой [[Handler]] будет коллбэком из then;
- Отложенный в Web API коллбек срабатывает, после чего отправляется в Task Queue;
- Если промис в состоянии pending, все Promise Reaction Record отправляются в очередь
[[PromiseFulfillReactions]]
- Вызов resolve меняет состояние промиса
[[PromiseState]]на fulfilled, а значение[[PromiseResult]]на Done!; - Cодержимое
[[PromiseFulfillReactions]]отправляется в Micro Task Queue, после чего resolve и callback удаляются из Call Stack;
Важно понимать, что отправка коллбека из setTimeout в очередь макрозадач не гарантирует его мгновенного вызова, так как он добавляется в конец этой очереди, что может отложить выполнение на какое-то время.
Чаще всего, это не критично, но бывает заметно во всяких анимациях, когда два коллбэка из двух разных setTimeout должны выполняться в одно время, ведь они попадут в разные макро задачи.
- Как только последний коллбэк выполнится, он будет удален из Call Stack, и выполнение промиса будет полностью завершено!
Если обобщить, промис — это синтаксический сахар над объектом со сложной внутренней логикой, включающей в себя различные состояния и очереди коллбэков.
Поздравляем!
Теперь вы не только знаете, как пользоваться промисами, но и понимаете, что происходит внутри. Это должно помочь вам писать более эффективный код и уверенно отвечать на каверзные вопросы на собеседованиях 🙂
Если вам понравилась статья — подписывайтесь на мой телеграм. Там я делюсь практическим опытом и инсайтами по разработке, которые помогут вам расти как специалисту ✨
Остались вопросы по статье? Давайте обсудим в комментариях 👇
2К открытий8К показов





