Перетяжка, Премия ТПрогер, 13.11
Перетяжка, Премия ТПрогер, 13.11
Перетяжка, Премия ТПрогер, 13.11

Глубокое погружение в работу промисов в JavaScript

Аватарка пользователя Дима Державин
для
Логотип компании Tproger
Tproger
Отредактировано

Как работают промисы в JavaScript: жизненный цикл, внутреннее устройство, примеры, история возникновения и практические советы от разработчиков.

2К открытий8К показов
Глубокое погружение в работу промисов в JavaScript

Промисы ежедневно встречаются в работе, но их внутренняя механика часто остаётся загадкой даже для опытных разработчиков.

Поэтому, в статье мы разберём:

  • Что стоит за изобретением промиса;
  • Как устроен промис «под капотом»;
  • Как устроен жизненный цикл промиса;
  • Как промис выполняется с точки зрения событийного цикла.

Опирались на перевод статьи Promise Execution и комментарии экспертов. Статья будет полезна как новичкам, которые только знакомятся с асинхронностью, так и сеньорам, которые уже не знают, какими вопросами можно замучить кандидата на собеседовании.

Как появились промисы?

До появления промисов результаты асинхронного кода обрабатывались при помощи функций-коллбэков. При большой вложенности асинхронных вызовов использование коллбеков приводило к проблемам:

  • Усложняется обработка ошибок — каждый вложенный уровень требует отдельную обработку;
  • Усложняется композиция кода — если в коллбэке обрабатывается несколько параллельных и последовательных запросов, код превращается в лабиринт вложенных коллбеков;
  • Снижается читабельность — код начинает жёстко уходить влево и при форматировании становится нечитаемым (pyramid of doom).
			fetchUser(42, (err, user) => {
  if (err) console.error("Error fetching user:", err)

  fetchUserPosts(user, (err, data) => {
    if (err) console.error("Error fetching posts:", err)

    data.posts.forEach(post => {
      fetchPostComments(post, (err, comments) => {
        if (err) console.error("Error fetching comments:", err)
        return comments
      });
    });
  });
});
		

Эти проблемы получили общее название, которое вы точно слышали — 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.
			new Promise((resolve, reject) => {
   // какая-то асинхронность
});

		

Функция executer имеет несколько особенностей:

executer вызывается сразу при создании промиса (до присваивания переменной):

			console.log("1 — выполнится до создания промиса")

const promise = new Promise((resolve) => {
  console.log("2 — выполнится сразу")
  resolve("4 — выполнится по завершению микротаска")
});

console.log("3 — выполнится после создания промиса")

promise.then((result) => {
  console.log(result)
});
		

Аргументы resolve и reject можно вызвать только один раз — последующие вызовы будут игнорироваться:

			const promise = new Promise((resolve) => {
  setTimeout(() => resolve("Первый вызов"), 1000)
  setTimeout(() => resolve("Второй вызов"), 2000) // Проигнорируется
});

promise.then((result) => {
  console.log(result) // Первый вызов
});
		

Необработанные ошибки в executer автоматически вызывают reject:

			new Promise(() => {
  JSON.parse("{")
});

// Без catch() упадёт с ошибкой — unhandled promise rejection

		

Промис не может быть создан с аргументом undefined:

			const promise = new Promise(undefined)

promise
  .then(() => console.log("Успех"))
  .catch((err) => console.log("Ошибка:", err))

// Упадёт с ошибкой — Uncaught TypeError: Promise resolver undefined is not a function
		
Помимо самого конструктора у 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;
  • При pendingundefined.
Эти свойства можно увидеть в консоли, но их нельзя как-то проверить в коде во время исполнения. Чтобы отобразить состояние промисов где-то в 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]]?

При создании промиса:

			[[PromiseState]] = "pending"
[[PromiseResult]] = undefined 

		

При вызове resolve("Done!"):

			[[PromiseState]] = "fulfilled"
[[PromiseResult]] = "Done!" 

		

Здесь всё просто — методами, изменяющими внутреннее состояние класса, сейчас никого не удивишь!

Теперь рассмотрим, как промис работает с методом then:

Обработка коллбэков в then

Для обработки коллбеков в then промис использует две очереди, которые уже упоминали ранее:

  • [[PromiseFulfillReactions]]
  • [[PromiseRejectReactions]]

При вызове resolve коллбеки, отправленные в [[PromiseFulfillReactions]], будут по очереди отправляться в Microtask Queue получая значение из resolve (result):

Именно в этом месте обрабатывается асинхронная часть промиса — её мы разберём позже.

Как только коллбеки из очереди будут выполнены, а финальное значение высчитано — промис возвратит содержимое [[PromiseResult]].

Визуализация жизненного цикла промиса с использованием асинхронности

До этого момента мы вызывали resolve или reject напрямую, внутри executer, без какой-либо асинхронности.

Давайте исправим — попробуем сделать resolve значения, которое придёт с задержкой:

			new Promise((resolve) => {
    setTimeout(() => resolve("Done!"), 100);
}).then(result => console.log(result))

		

Шаг за шагом рассмотрим, что будет происходить в это время под капотом:

  • Конструктор 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К показов