ПроКодинг - Откроем для вас мир IT!

Вы когда-нибудь чувствовали себя беспомощным, глядя на Promise в JavaScript? Вы создали его, но не можете заставить его завершиться именно тогда, когда вам нужно. Стандартные методы вроде .then() или await работают отлично для линейных сценариев, но что делать, если результат зависит от внешнего события, которое произойдет через неизвестное время? Здесь на сцену выходят отложенные промисы и ручное управление состоянием через resolve и reject.

Это не просто синтаксический сахар. Это инструмент контроля. В этой статье мы разберем, почему иногда стандартный подход ломается, как правильно создавать «ручные» промисы и где это реально спасает код от хаоса.

Почему стандартный Promise не всегда удобен

Обычно вы пишете так:

const data = await fetchData();
console.log(data);

Здесь все предсказуемо. Функция fetchData сама решает, когда вернуть данные или ошибку. Но представьте другую ситуацию. Вам нужно дождаться, пока пользователь нажмет кнопку, или пока WebSocket получит сообщение. В момент вызова функции вы еще не знаете, сколько придется ждать. Если обернуть обработчик события в обычный промис, код становится громоздким и сложным для чтения.

Проблема классического подхода в том, что он связывает создание асинхронной операции и ее ожидание в одном месте. А иногда эти моменты должны быть разделены во времени и пространстве кода. Ручной контроль позволяет создать «пустую оболочку» ожидания сейчас и наполнить ее результатом позже.

Анатомия ручного управления

Конструктор new Promise((resolve, reject) => { ... }) дает вам две функции-колбэки. Обычно их вызывают сразу внутри конструктора. Но ничто не мешает сохранить эти ссылки в переменные и вызвать их в любой момент жизни приложения.

Сравнение подходов к управлению Promise
Критерий Стандартный Promise Ручной (Deferred)
Контроль завершения Внутри тела конструктора Извне, в любой точке кода
Читаемость Высокая для простых задач Ниже, требует аккуратности
Гибкость Ограничена логикой создания Максимальная
Риск утечек памяти Низкий Высокий при отсутствии очистки

Ключевой момент здесь - инкапсуляция. Вы создаете объект, который хранит в себе ссылку на функцию resolve, и возвращаете наружу только сам промис и метод для его разрешения. Это паттерн Deferred, который был популярен в jQuery, но теперь реализуется нативно.

Практический пример: ожидание действия пользователя

Допустим, у вас есть модальное окно подтверждения. Логика приложения должна остановиться и ждать, пока пользователь не выберет «Да» или «Нет». Без ручного промиса вам пришлось бы передавать колбэки повсюду или использовать сложные цепочки событий.

Вот как это выглядит с использованием вспомогательной функции:

function createDeferred() {
  let resolve;
  let reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  return { promise, resolve, reject };
}

// Использование:
const confirmation = createDeferred();

// Где-то в коде UI:
document.getElementById('yes-btn').onclick = () => confirmation.resolve(true);
document.getElementById('no-btn').onclick = () => confirmation.reject(new Error('User cancelled'));

// Где-то в бизнес-логике:
async function processAction() {
  try {
    const confirmed = await confirmation.promise;
    console.log('Proceeding with action');
  } catch (e) {
    console.log('Action cancelled by user');
  }
}

Заметьте, как чисто разделяются ответственность интерфейса и логики. Интерфейс просто дергает кнопку «разрешить», а логика спокойно ждет результата. Никаких вложенных функций, никаких «callback hell».

Изометрическая иллюстрация ручного управления состоянием промиса через внешние триггеры

Синхронизация потоков данных

Еще один мощный кейс - работа с потоками, которые приходят неравномерно. Например, вы собираете ответы от нескольких микро-сервисов, но хотите продолжить выполнение, только когда придут все данные, даже если они прилетают в разном порядке и в разное время.

Иногда проще создать массив из отложенных промисов, передать их в Promise.all, а затем разрешать каждый из них по мере поступления данных из WebSocket или SSE (Server-Sent Events). Это превращает хаотичный поток сообщений в структурированный набор результатов.

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

Ловушки и подводные камни

Ручное управление - это сила, но она опасна. Первая главная ошибка - забыть разрешить промис. Если вы создали отложенный промис, но условие завершения никогда не наступило (например, закрылось соединение), ваш код зависнет навсегда в состоянии pending. Память будет держать этот объект, и приложение может начать тормозить.

Вторая проблема - множественное разрешение. По спецификации ECMAScript, промис можно разрешить только один раз. Повторный вызов resolve или reject игнорируется. Это хорошо защищает от гонок, но скрывает баги в логике. Если вы случайно вызовете resolve дважды, второй вызов молча пропадет, и вы можете долго искать, почему изменились данные, но промис вернул старый результат.

Третья ловушка - утечки слушателей событий. Если вы сохраняете ссылку на resolve внутри обработчика события DOM, обязательно удаляйте этот обработчик после использования. Иначе вы будете накапливать лишние функции в памяти.

Концептуальное изображение висящих в памяти незавершенных промисов как призрачных цепей

Когда стоит избегать ручных промисов

Не используйте этот паттерн везде подряд. Для большинства HTTP-запросов, таймеров (setTimeout) или файловых операций стандартный подход лучше. Он короче, понятнее и безопаснее.

Ручной resolve/reject оправдан только в трех случаях:

  • Разделение во времени: момент создания обещания и момент его исполнения сильно разнесены.
  • Внешние триггеры: завершение зависит от действий пользователя или внешних систем, которыми нельзя управлять напрямую из тела функции.
  • Композиция сложных потоков: когда нужно агрегировать данные из множества источников с непредсказуемой скоростью поступления.

Если вы ловите себя на мысли, что создаете глобальную переменную только ради того, чтобы сохранить туда resolve, возможно, стоит пересмотреть архитектуру. Часто такие ситуации решаются более элегантными абстракциями, например, RxJS Observables или AbortController для отмены запросов.

Лучшие практики реализации

Чтобы ваш код оставался поддерживаемым, следуйте простым правилам. Всегда оборачивайте логику разрешения в проверки. Убедитесь, что промис еще не завершен, прежде чем вызывать resolve. Хотя спецификация защищает от двойного вызова, явные проверки делают намерения разработчика понятнее.

Используйте TypeScript. Типизация помогает не потерять ссылку на resolve и четко определить, какой тип данных должен прийти в итоге. Это особенно важно при работе с большими командами.

И наконец, документруйте. Если вы создаете сложный механизм отложенных промисов, добавьте комментарий, объясняющий, кто и когда отвечает за разрешение этого конкретного объекта. Через полгода вы скажете себе спасибо.

Можно ли повторно использовать уже разрешенный Promise?

Нет, состояние Promise неизменно. Как только он переходит в статус fulfilled или rejected, его значение фиксируется навсегда. Попытка изменить его через повторный вызов resolve/reject не увенчается успехом. Для повторного использования нужно создавать новый экземпляр.

Что происходит, если никогда не вызвать resolve или reject?

Промис останется в состоянии pending бесконечно. Любая конструкция await, ожидающая этот промис, заблокирует выполнение текущей асинхронной функции. Объект промиса и связанные с ним замыкания останутся в памяти, что может привести к утечкам, если таких объектов много.

Есть ли нативная поддержка Deferred в современных браузерах?

Нет, нативного класса Deferred в стандарте ECMAScript нет. Однако реализовать его тривиально с помощью замыканий, как показано в примерах выше. Библиотеки вроде Bluebird предоставляли эту функциональность раньше, но сейчас достаточно нескольких строк собственного кода.

Как отменить отложенный промис?

У Promise нет встроенного метода отмены. Вы можете имитировать отмену, вызывая reject с специальной ошибкой (например, AbortError). Также полезно использовать AbortController в связке с fetch или другими асинхронными операциями, чтобы прерывать нижележащие процессы, а не только ожидать завершения.

Почему не рекомендуется использовать глобальные переменные для хранения resolve?

Глобальные переменные создают неявные зависимости и риск конфликтов имен. Если два компонента приложения используют одну и ту же глобальную переменную для хранения ссылки на resolve, они могут перезаписать друг друга. Лучше инкапсулировать состояние внутри модулей или классов.