Cancellable Promise

Promise в JavaScript отменяется не «по-настоящему» — мы отменяем не саму операцию, а интерес к её результату. Стандартный API — AbortController/AbortSignal. Свой Cancellable Promise полезен, когда нужно отменить произвольную асинхронную работу.

Что это / Зачем

Promise в спецификации не имеет встроенной отмены. Если вы отправили HTTP-запрос — отмена Promise не остановит сетевой пакет. Отмена в JS — это:

  1. Игнорирование результата операции (не вызываем resolve/reject).
  2. По возможности — реальное прерывание операции (xhr.abort(), fetch с AbortController, очистка setTimeout).

API / Синтаксис

// 1. Стандартный путь — AbortController (см. [[AbortController]])
const ctrl = new AbortController();
fetch('/api', { signal: ctrl.signal });
ctrl.abort();

// 2. Cancellable обёртка над произвольной операцией
function cancellable(promise) {
  let cancelled = false;
  const wrapped = new Promise((resolve, reject) => {
    promise.then(
      (value) => (cancelled ? reject({ cancelled: true }) : resolve(value)),
      (error) => (cancelled ? reject({ cancelled: true }) : reject(error)),
    );
  });
  wrapped.cancel() = () => { cancelled = true; };
  return wrapped;
}

// 3. С функцией onCancel (полная отмена операции)
function makeCancellable(executor) {
  let onCancel;
  let cancelled = false;
  const promise = new Promise((resolve, reject) => {
    executor(
      (v) => !cancelled && resolve(v),
      (e) => !cancelled && reject(e),
      (fn) => (onCancel = fn), // третий аргумент — регистрация cancel
    );
  });
  promise.cancel() = () => {
    cancelled = true;
    if (onCancel) onCancel;
  };
  return promise;
}

const req = makeCancellable((resolve, reject, onCancel) => {
  const xhr = new XMLHttpRequest();
  xhr.onload = () => resolve(xhr.responseText);
  xhr.onerror = () => reject(new Error('fail'));
  xhr.open('GET', '/api');
  xhr.send;
  onCancel( => xhr.abort());
});
req.cancel(); // и игнор результата, и abort

Ключевые моменты

  • Отменяем не операцию, а результат. Чтобы отменить операцию, нужен механизм самой операции (AbortController, xhr.abort(), clearTimeout).
  • Третий параметр executor'а onCancel — антипаттерн в API стандарта, но удобный приём для своих абстракций.
  • Отменённый промис остаётся в состоянии pending, если мы не вызовем reject — это часто нежелательно (память). Лучше переводить в rejected с маркером { cancelled: true }.
  • Современная альтернатива — везде использовать AbortSignal (поддерживается fetch, addEventListener, async iterators, web streams).

Подводные камни

  • Наследование от Promise (class Cancellable extends Promise) — выглядит логично, но then/catch будут возвращать Cancellable, а Promise-методы вроде Promise.all могут вернуть Cancellable неожиданно. Часто чище — обёртка без наследования.
  • Cancel должен быть идемпотентен и однократен — повторный вызов не должен ломать состояние.
  • Если работаете с async/await — отмена приходит как reject с особым типом ошибки (AbortError), его надо отличать от обычной ошибки.

🎓 Источники

  • 🎓 [Отмена асинхронных операций, cancellable callback and Promise в JavaScript] · 2019-05-02 · YouTube
    • Тезисы:
      • Отменяем НЕ операцию, а интерес к её результату.
      • Реально отменимы: setTimeout (clearTimeout), XHR (abort), fetch (AbortController), подписки на события.
      • Третий аргумент onCancel в executor'е — приём для регистрации действий на отмену.
      • Наследование class extends Promise для cancel — антипаттерн.
      • Синхронный cancel в момент создания корректно отменяет и не-стартовавшие подписки.
    • Цитата:

      «На самом деле мы отменяем не саму операцию, а результат. Операция уже идёт — её отменить может только тот, кто её запустил.»

  • ⚡ [LeetCode Решаем hard задачу Design Cancellable Function] · AsForJS · 2023-11-28 · YouTube
    • Тезисы:
      • LeetCode: отмена generator-based async через next/throw — отмена реализуется через бросок исключения в генератор.
      • Promise+race с reject-сигналом — простейший прикладной паттерн отмены.
  • ⚡ [Design Cancellable Function продолжение] · AsForJS · 2023-11-28 · YouTube

См. также