За пределами async/await: мощные асинхронные паттерны в современном JavaScript

Асинхронное программирование — не просто особенность JavaScript, это его жизненная необходимость. За последнее десятилетие мы прошли путь от callback hell до элегантного async/await. Но что дальше? Оказывается, современная экосистема предлагает богатый набор инструментов для сложных асинхронных сценариев, выходящий далеко за пределы базовых промисов.

Рассмотрим паттерны, которые решают реальные проблемы: управление параллелизмом, отмена операций, обработка потоков данных и эффективная работа с событиями.

Контроль параллелизма: когда Promise.all недостаточно

Классический Promise.all() похож на швейцарский нож: универсален, но опасен при неправильном использовании. Что если у вас 1000 запросов, но лимит API разрешает только 10 одновременно?

javascript
async function throttleRequests(urls, maxConcurrency = 5) {
  const results = [];
  const executing = new Set();
  
  for (const url of urls) {
    const promise = fetch(url).then(res => res.json());
    results.push(promise);
    const clean = () => executing.delete(promise);
    promise.then(clean).catch(clean);
    
    executing.add(promise);
    
    if (executing.size >= maxConcurrency) {
      await Promise.race(executing);
    }
  }
  
  return Promise.all(results);
}

// Использование
const urls = [/* массив из 100 URL */];
throttleRequests(urls, 8)
  .then(data => console.log('Все запросы завершены с контролем потока'));

Как это работает:

  • Мы создаём Set executing для отслеживания выполняющихся запросов
  • Для каждого URL создаём промис, добавляя ловушки очистки
  • При достижении лимита ожидаем завершения любого запроса с помощью Promise.race
  • Возвращаем все результаты после завершения

Подход эффективнее очереди с фиксированными интервалами, поскольку новые запросы запускаются сразу после освобождения "слота".

Безопасная отмена операций с AbortController

Обещание "отменить промис" давно стало мемом, но с AbortController это реальность. Особенно полезно в интерфейсах, где пользовательские действия могут делать запросы неактуальными.

javascript
function fetchWithCancel(url, { signal } = {}) {
  if (signal && signal.aborted) {
    return Promise.reject(new DOMException('Aborted', 'AbortError'));
  }
  
  let rejector;
  const controller = new AbortController();
  
  const promise = fetch(url, {
    signal: controller.signal
  }).then(response => {
    if (!response.ok) throw new Error('HTTP error');
    return response.json();
  });
  
  if (signal) {
    signal.addEventListener('abort', () => {
      controller.abort();
      rejector(new DOMException('Aborted', 'AbortError'));
    });
  }
  
  return Object.assign(promise, {
    abort: () => controller.abort()
  });
}

// Использование
const controller = new AbortController();
const { signal } = controller;

fetchWithCancel('https://api.example.com/data', { signal })
  .then(data => console.log(data))
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('Запрос отменён пользователем');
    } else {
      console.error('Ошибка запроса:', err);
    }
  });

// Через 3 сек отменяем запрос
setTimeout(() => controller.abort(), 3000);

Ключевые детали:

  • Композиция сигналов позволяет передавать сигнал отмены через несколько уровней
  • Использование DOMException обеспечивает совместимость с браузерными API
  • При отмене корректно происходит очистка ресурсов

Асинхронные итераторы для потоковой обработки

Когда данные поступают порциями (файлы, сетевые потоки) или вы работаете с paginated API, асинхронные итераторы становятся бесценным инструментом.

javascript
async function* paginatedFetcher(endpoint, options = {}) {
  let page = 1;
  let hasMore = true;
  
  while (hasMore) {
    const response = await fetch(`${endpoint}?page=${page}`, options);
    const data = await response.json();
    
    yield data.items;
    
    page++;
    hasMore = data.has_more;
  }
}

// Использование с for await...of
async function processItems() {
  const itemsFetcher = paginatedFetcher('https://api.example.com/items');
  
  for await (const batch of itemsFetcher) {
    // Обрабатываем каждую порцию данных
    console.log(`Получено ${batch.length} элементов`);
    batch.forEach(item => processItem(item));
    // Можем прервать в любой момент
    if (shouldStopProcessing()) break;
  }
}

Преимущества перед пакетной загрузкой:

  • Отсутствие накопления данных в памяти
  • Возможность начать обработку с первой порции
  • Лёгкая интеграция с другими асинхронными операциями
  • Корректная обработка прерываний

Реактивные события с AsyncPubSub

Когда традиционные EventEmitters становятся слишком неудобными из-за callback hell, комбинация промисов и паблишера/сабскрайбера даёт элегантное решение.

javascript
class AsyncPubSub {
  constructor() {
    this.subscriptions = new Set();
  }

  subscribe() {
    const handler = {};
    const promise = new Promise((resolve, reject) => {
      handler.resolve = resolve;
      handler.reject = reject;
    });

    this.subscriptions.add(handler);
    return { 
      promise,
      unsubscribe: () => this.subscriptions.delete(handler) 
    };
  }

  publish(value) {
    for (const handler of this.subscriptions) {
      handler.resolve(value);
      this.subscriptions.delete(handler);
    }
  }

  error(error) {
    for (const handler of this.subscriptions) {
      handler.reject(error);
      this.subscriptions.delete(handler);
    }
  }
}

// Использование
const messageBus = new AsyncPubSub();

// Publisher
document.getElementById('send-btn').addEventListener('click', async () => {
  const message = input.value;
  messageBus.publish(message);
});

// Consumer
(async () => {
  while (true) {
    try {
      const { promise, unsubscribe } = messageBus.subscribe();
      const message = await promise;
      console.log('Получено сообщение:', message);
      // Обработка сообщения...
    } catch (err) {
      console.error('Ошибка в обработке сообщения:', err);
    }
  }
})();

Чем это отличается от стандартных решений:

  • Потребители ожидают сообщения с помощью промисов вместо колбеков
  • Каждое сообщение ожидается явно, что исключает накопление колбеков
  • Поддержка режима "одиночного потребления" сообщений

Стратегии обработки ошибок в асинхронных конвейерах

Простые try/catch блоки часто не справляются со сложными асинхронными потоками. Рассмотрим паттерны для отказоустойчивых систем.

Схема повторных попыток с экспоненциальной отсрочкой:

javascript
async function resilientRequest(url, options = {}) {
  const maxRetries = options.maxRetries || 5;
  const initialDelay = options.initialDelay || 100;
  
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);
      if (!response.ok) throw new Error(`HTTP status ${response.status}`);
      return await response.json();
    } catch (error) {
      if (attempt === maxRetries) throw error;
      
      // Экспоненциальная отсрочка
      const delay = initialDelay * Math.pow(2, attempt);
      console.log(`Попытка ${attempt + 1} не удалась, повтор через ${delay}ms`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

Асинхронный Circuit Breaker (автомат защиты от сбоев):

javascript
class CircuitBreaker {
  constructor(action, threshold = 5, timeout = 10000) {
    this.action = action;
    this.threshold = threshold;
    this.timeout = timeout;
    this.failures = 0;
    this.state = 'CLOSED';
  }

  async exec(...args) {
    if (this.state === 'OPEN') {
      throw new Error('Circuit open');
    }

    try {
      const result = await this.action(...args);
      this.failures = 0; // Сброс счётчика при успехе
      return result;
    } catch (err) {
      this.failures++;
      if (this.failures >= this.threshold) {
        this.state = 'OPEN';
        setTimeout(() => {
          this.state = 'HALF_OPEN';
          this.failures = threshold - 1; // Даём один шанс
        }, this.timeout);
      }
      throw err;
    }
  }
}

Реальные ограничения и компромиссы

При всей мощи продвинутых паттернов помните о фундаментальных ограничениях:

  1. Сложность отладки: Асинхронный стек вызовов часто теряет контекст Решение: Используйте async stack traces (доступны в V8)

  2. Память: Цепочки промисов и обработчиков могут создавать утечки памяти Решение: Отписывайтесь от событий, избегайте замыканий на большие объекты

  3. Производительность: Микрозадачи создают накладные расходы Решение: Крупные пакетные операции лучше обрабатывать воркерами

Когда что использовать: практическое руководство

ПаттернИдеальные условия примененияКогда стоит избегать
Ограничение потоковМножество I/O операций с лимитамиДля CPU-задач вместо worker pool
AbortControllerРеактивные UI, долгие HTTP-запросыКороткие атомарные операции
Асинхронные итераторыПотоковая обработка, пейджингПростые запросы одного ресурса
AsyncPubSubКоммуникация между компонентамиДля state management стоит использовать Redux/Zustand
Стратегии повторенияНенадёжные сетевые подключенияПри логических ошибках в коде

Универсальных решений не существует. Выбор зависит от конкретного контекста:

  • Для транзакционных API с высокими SLA инкрементальные повторения критичны
  • В мобильных приложениях важнее отмена операций и экономия трафика
  • При обработке больших файлов потоковые подходы незаменимы

Профессиональное развитие

Асинхронное программирование эволюционирует быстро. Сегодня рассматриваем ReactiveX (RxJS) не как экзотику, а как практический инструмент для сложных потоков событий. Концепция observable продолжает проникать в нативные API — посмотрите на новые Web Streams.

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