Управление асинхронностью в современном JavaScript: за пределами await

javascript
async function fetchUserData(userId) {
  const response = await fetch(`/api/users/${userId}`);
  if (!response.ok) throw new Error('Failed to fetch user');
  return response.json();
}

Кажется простым? Но когда ваш сервис растёт, а требования усложняются, этот лаконичный шаблон превращается в минное поле необработанных исключений, гонок ресурсов и неуправляемых зависимостей. Асинхронные операции — фундамент современных веб-приложений, но мало кто использует async/await за пределами поверхностного применения.

Анатомия асинхронных проблем

Рассмотрим типичные сбои в реальных проектах:

  1. Молчаливые падения
javascript
// Ошибка "съедается" без обработки
const user = await fetchUserData(userId);
updateUI(user);
  1. Последовательный ад
javascript
// Линейная цепочка замедляет работу
const profile = await loadProfile();
const posts = await loadPosts(); // Ожидание завершения loadProfile
const friends = await loadFriends(); // Ожидание предыдущих
  1. Неуправляемые операции
javascript
// Запрос нельзя отменить
async function search(query) {
  const results = await fetchResults(query);
  // Что если пользователь изменил запрос?
}

Эволюция инструментов управления

Обработка ошибок без try/catch ада

Использование try/catch для каждого асинхронного вызова создаёт шаблонный шум. Альтернатива — обёртка:

javascript
function safeAsync(promise) {
  return promise
    .then(data => [null, data])
    .catch(error => [error, null]);
}

// На практике
const [userError, user] = await safeAsync(fetchUserData(123));

if (userError) {
  handleError(userError);
  return;
}

processUser(user);

Для TypeScript добавляем типизацию:

typescript
function safeAsync<T>(promise: Promise<T>): Promise<[Error | null, T | null]> {
  return promise
    .then<[null, T]>((data) => [null, data])
    .catch<[Error, null]>((err) => [err instanceof Error ? err : new Error(String(err)), null]);
}

Параллелизм без боли

Распространённая антипаттерн — последовательные запросы:

javascript
// Медленный вариант
const a = await fetchA();
const b = await fetchB(); 

Решение — параллельное выполнение:

javascript
const [a, b] = await Promise.all([fetchA(), fetchB()]);

Но что если требуется более сложная логика? Используйте динамическое управление:

javascript
const controllers = new Map();

async function fetchWithCancel(id, signal) {
  const resp = await fetch(`/data/${id}`, { signal });
  return resp.json();
}

fetchItems(ids) {
  ids.forEach(id => {
    const controller = new AbortController();
    controllers.set(id, controller);
    fetchWithCancel(id, controller.signal);
  });
}

// Отмена конкретного запроса
function cancelFetch(id) {
  controllers.get(id)?.abort();
  controllers.delete(id);
}

Отмена операций через AbortController

AbortController — фундамент для управления жизненным циклом запросов:

javascript
const searchController = new AbortController();

async function searchProducts(query) {
  try {
    const response = await fetch(`/search?q=${query}`, {
      signal: searchController.signal
    });
    return response.json();
  } catch (err) {
    if (err.name === 'AbortError') {
      console.log('Запрос отменён');
      return [];
    }
    throw err;
  }
}

// При изменении поискового запроса
function handleSearchChange(query) {
  searchController.abort();  // Отмена предыдущего запроса
  searchController = new AbortController();
  searchProducts(query);
}

Для комплексной отмены цепочки операций:

javascript
function createCancellableAsync(task) {
  const controller = new AbortController();
  
  const promise = task(controller.signal);
  
  return {
    promise,
    cancel: () => controller.abort()
  };
}

// Использование
const { promise, cancel } = createCancellableAsync(async (signal) => {
  const user = await fetchUser({ signal });
  const orders = await fetchOrders(user.id, { signal });
  return { user, orders };
});

// По событию отмены
cancelButton.addEventListener('click', cancel);

Расширенный параллелизм с управлением

Promise.all бросает ошибку при первом сбое — неприемлемо для независимых операций. Решение: Promise.allSettled

javascript
const results = await Promise.allSettled([
  fetchConfig(),
  fetchUserData(),
  loadPreferences()
]);

const successfulResults = results
  .filter(result => result.status === 'fulfilled')
  .map(result => result.value);

const errors = results
  .filter(result => result.status === 'rejected')
  .map(result => result.reason);

Для управления скоростью очередь с ограниченным параллелизмом:

javascript
class TaskQueue {
  constructor(concurrency) {
    this.concurrency = concurrency;
    this.running = 0;
    this.queue = [];
  }

  addTask(task) {
    return new Promise((resolve, reject) => {
      this.queue.push({
        task,
        resolve,
        reject
      });
      this.next();
    });
  }

  next() {
    while (this.running < this.concurrency && this.queue.length) {
      const { task, resolve, reject } = this.queue.shift();
      this.running++;
      
      task()
        .then(resolve)
        .catch(reject)
        .finally(() => {
          this.running--;
          this.next();
        });
    }
  }
}

// Использование: максимум 3 одновременных запроса
const apiQueue = new TaskQueue(3);

const results = await Promise.all([
  apiQueue.addTask(() => fetch('/data/1')),
  apiQueue.addTask(() => fetch('/data/2')),
  // ... 10 задач
]);

Паттерны для цепочек зависимостей

Когда операции зависят друг от друга, чистая последовательность — не всегда оптимальное решение:

javascript
async function loadDashboard() {
  const [userErr, user] = await safeAsync(fetchUser());
  if (userErr) throw userErr;
  
  const [prefsErr, prefs] = await safeAsync(fetchPrefs(user.id));
  if (prefsErr) throw prefsErr;
  
  const [notificationsErr, notifications] = await safeAsync(
    fetchNotifications(user.id, prefs.lastSeen)
  );
  
  return { user, prefs, notifications };
}

Перепишем с эффекторным подходом:

javascript
const dashboardData = {
  user: null,
  prefs: null,
  notifications: null
};

const fetchOperations = [
  async () => { dashboardData.user = await fetchUser(); },
  async () => { 
    if (!dashboardData.user) return;
    dashboardData.prefs = await fetchPrefs(dashboardData.user.id);
  },
  async () => {
    if (!dashboardData.prefs) return;
    dashboardData.notifications = await fetchNotifications(
      dashboardData.user.id, 
      dashboardData.prefs.lastSeen
    );
  }
];

await Promise.allSettled(fetchOperations.map(op => op()));

Границы разумного применения

Не всё требует асинхронных решений. Задумайтесь:

  1. Стоит ли микрооптимизировать?
    Для 1-3 последовательных вызовов разница с Promise.all() часто незначительна

  2. Гранулярность ошибок
    groupFetch может скрывать критичные сбои среди второстепенных

  3. Статус выполнения
    Добавьте прогресс-индикаторы для операций, занимающих >200мс:

javascript
const progress = new ProgressEmitter();

async function bulkUpload(files) {
  progress.started(files.length);
  
  await Promise.all(files.map(async (file, index) => {
    await uploadFile(file);
    progress.increment();
  }));
  
  progress.complete();
}

Интеграция с состоянием приложения

В React-приложении используйте хук для управляемых запросов:

javascript
function useAbortableFetch() {
  const abortControllerRef = useRef(null);
  
  useEffect(() => {
    return () => abortControllerRef.current?.abort();
  }, []);
  
  return async (url, options) => {
    abortControllerRef.current?.abort();
    const controller = new AbortController();
    abortControllerRef.current = controller;
    
    try {
      const response = await fetch(url, { 
        ...options, 
        signal: controller.signal 
      });
      return response.json();
    } catch (err) {
      if (err.name !== 'AbortError') throw err;
    }
  };
}

Корректное тестирование

Используйте временные моки для асинхронных тестов:

javascript
test('should cancel previous request', async () => {
  const fetchSpy = jest.fn();
  const search = createSearch(fetchSpy);
  
  search('react');
  search('angular'); // Отменяет "react"
  
  await new Promise(resolve => setTimeout(resolve, 10));
  
  expect(fetchSpy).toHaveBeenCalledTimes(2);
  expect(fetchSpy.mock.calls[0][0].signal.aborted).toBe(true);
  expect(fetchSpy.mock.calls[1][0].signal.aborted).toBe(false);
});

Итоговые рекомендации

  1. Ошибки
    Всегда обрабатывайте ошибки явно. Реализуйте стратегии retry для временных сбоев.

  2. Параллелизм
    Используйте Promise.all и группируйте независимые запросы. Добавляйте ограничители при работе с лимитиями API.

  3. Отмена
    Используйте AbortController для всех долгих операций. Подключайте к пользовательским событиям (перезапросы, покидание страницы).

  4. Зависимости
    Для последовательного выполнения с зависимостями используйте явный синтаксис async/await без over-engineering.

  5. Отзывчивость
    Предусматривайте показатели загрузки для действий, занимающих свыше 100 мс.

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