Асинхронное программирование — не просто особенность JavaScript, это его жизненная необходимость. За последнее десятилетие мы прошли путь от callback hell до элегантного async/await. Но что дальше? Оказывается, современная экосистема предлагает богатый набор инструментов для сложных асинхронных сценариев, выходящий далеко за пределы базовых промисов.
Рассмотрим паттерны, которые решают реальные проблемы: управление параллелизмом, отмена операций, обработка потоков данных и эффективная работа с событиями.
Контроль параллелизма: когда Promise.all недостаточно
Классический Promise.all()
похож на швейцарский нож: универсален, но опасен при неправильном использовании. Что если у вас 1000 запросов, но лимит API разрешает только 10 одновременно?
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
это реальность. Особенно полезно в интерфейсах, где пользовательские действия могут делать запросы неактуальными.
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, асинхронные итераторы становятся бесценным инструментом.
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, комбинация промисов и паблишера/сабскрайбера даёт элегантное решение.
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 блоки часто не справляются со сложными асинхронными потоками. Рассмотрим паттерны для отказоустойчивых систем.
Схема повторных попыток с экспоненциальной отсрочкой:
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 (автомат защиты от сбоев):
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;
}
}
}
Реальные ограничения и компромиссы
При всей мощи продвинутых паттернов помните о фундаментальных ограничениях:
-
Сложность отладки: Асинхронный стек вызовов часто теряет контекст Решение: Используйте async stack traces (доступны в V8)
-
Память: Цепочки промисов и обработчиков могут создавать утечки памяти Решение: Отписывайтесь от событий, избегайте замыканий на большие объекты
-
Производительность: Микрозадачи создают накладные расходы Решение: Крупные пакетные операции лучше обрабатывать воркерами
Когда что использовать: практическое руководство
Паттерн | Идеальные условия применения | Когда стоит избегать |
---|---|---|
Ограничение потоков | Множество I/O операций с лимитами | Для CPU-задач вместо worker pool |
AbortController | Реактивные UI, долгие HTTP-запросы | Короткие атомарные операции |
Асинхронные итераторы | Потоковая обработка, пейджинг | Простые запросы одного ресурса |
AsyncPubSub | Коммуникация между компонентами | Для state management стоит использовать Redux/Zustand |
Стратегии повторения | Ненадёжные сетевые подключения | При логических ошибках в коде |
Универсальных решений не существует. Выбор зависит от конкретного контекста:
- Для транзакционных API с высокими SLA инкрементальные повторения критичны
- В мобильных приложениях важнее отмена операций и экономия трафика
- При обработке больших файлов потоковые подходы незаменимы
Профессиональное развитие
Асинхронное программирование эволюционирует быстро. Сегодня рассматриваем ReactiveX (RxJS) не как экзотику, а как практический инструмент для сложных потоков событий. Концепция observable продолжает проникать в нативные API — посмотрите на новые Web Streams.
Показанные паттерны не теоретические конструкции, а инструменты, которыми мы пользуемся ежедневно при обработке миллионов запросов. Их глубокое понимание отделяет разработчика высокого уровня от начинающего специалиста. Главное не переусердствовать — применяйте паттерны там, где они действительно решают избежать проблем, а не создают новые.