Async

🏠

Модуль 8: Асинхронность и API

Мастерство работы с асинхронным кодом, Promises, async/await и Fetch API.

Синхронность vs Асинхронность

Синхронный код

// Синхронный — выполняется по порядку
console.log('1');
console.log('2');
console.log('3');
// Вывод: 1, 2, 3

// Блокирующая операция
const start = Date.now();
while (Date.now() - start < 3000) {} // Блокирует на 3 сек!
console.log('Готово'); // Только через 3 секунды

Асинхронный код

// Асинхронный — не блокирует
console.log('1');

setTimeout(() => {
  console.log('2'); // Выполнится позже
}, 1000);

console.log('3');
// Вывод: 1, 3, 2

// Event Loop обрабатывает асинхронные операции

Event Loop

Как работает Event Loop

  1. Call Stack — выполняет синхронный код
  2. Web APIs — обрабатывают async операции (setTimeout, fetch)
  3. Task Queue (Macrotask) — setTimeout, setInterval, I/O
  4. Microtask Queue — Promise, queueMicrotask (приоритет!)
  5. Event Loop переносит задачи из очередей в Call Stack
console.log('1'); // Синхронно

setTimeout(() => console.log('2'), 0); // Macrotask

Promise.resolve().then(() => console.log('3')); // Microtask

console.log('4'); // Синхронно

// Вывод: 1, 4, 3, 2
// Microtask выполняется раньше macrotask!

Callbacks

Что такое callback

// Callback — функция, переданная как аргумент
function fetchData(callback) {
  setTimeout(() => {
    const data = { name: 'Иван' };
    callback(data);
  }, 1000);
}

fetchData((data) => {
  console.log(data.name); // "Иван" через 1 сек
});

Callback Hell

//  Проблема: вложенные callbacks
getUser(userId, (user) => {
  getOrders(user.id, (orders) => {
    getOrderDetails(orders[0].id, (details) => {
      getProduct(details.productId, (product) => {
        console.log(product);
        // Это "Callback Hell" или "Pyramid of Doom"
      });
    });
  });
});

Проблемы callbacks

  • Вложенность (Callback Hell)
  • Сложная обработка ошибок
  • Трудно читать и поддерживать

Promises

Что такое Promise

Promise — объект, представляющий результат асинхронной операции.

// Создание Promise
const promise = new Promise((resolve, reject) => {
  // Асинхронная операция
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve({ data: 'Данные' }); // Успех
    } else {
      reject(new Error('Ошибка'));  // Неудача
    }
  }, 1000);
});

// Использование
promise
  .then(result => console.log(result))
  .catch(error => console.error(error));

Состояния Promise

Состояние Описание
pending Ожидание (начальное)
fulfilled Выполнено успешно (resolve)
rejected Отклонено (reject)

Цепочки Promise (Chaining)

//  Решение Callback Hell
getUser(userId)
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetails(orders[0].id))
  .then(details => getProduct(details.productId))
  .then(product => console.log(product))
  .catch(error => console.error('Ошибка:', error));

// Каждый .then возвращает новый Promise
fetch('/api/user')
  .then(response => response.json())  // Возвращает Promise
  .then(data => {
    console.log(data);
    return data.id;  // Обычное значение — оборачивается в Promise
  })
  .then(id => fetch(`/api/orders/${id}`))
  .then(response => response.json())
  .then(orders => console.log(orders));

Статические методы Promise

// Promise.resolve / Promise.reject
Promise.resolve(42).then(x => console.log(x)); // 42
Promise.reject(new Error('Oops')).catch(e => console.error(e));

// Promise.all — ждёт ВСЕ, падает при первой ошибке
const results = await Promise.all([
  fetch('/api/users'),
  fetch('/api/posts'),
  fetch('/api/comments')
]);

// Promise.allSettled — ждёт ВСЕ, не падает
const results = await Promise.allSettled([
  fetch('/api/users'),
  fetch('/api/might-fail')
]);
// [{status: 'fulfilled', value: ...}, {status: 'rejected', reason: ...}]

// Promise.race — первый завершившийся (успех или ошибка)
const fastest = await Promise.race([
  fetch('/api/server1'),
  fetch('/api/server2')
]);

// Promise.any — первый УСПЕШНЫЙ
const firstSuccess = await Promise.any([
  fetch('/api/might-fail'),
  fetch('/api/might-succeed')
]);

async/await

Синтаксис

// async функция всегда возвращает Promise
async function getData() {
  return 'данные';
}
getData().then(console.log); // 'данные'

// await приостанавливает выполнение до resolve
async function fetchUser() {
  const response = await fetch('/api/user');
  const user = await response.json();
  return user;
}

// Сравнение
// Promise:
function getUser() {
  return fetch('/api/user')
    .then(res => res.json())
    .then(user => user);
}

// async/await:
async function getUser() {
  const res = await fetch('/api/user');
  const user = await res.json();
  return user;
}

Последовательные vs Параллельные запросы

//  Последовательно (медленно!)
async function fetchAll() {
  const users = await fetch('/api/users');    // 1 сек
  const posts = await fetch('/api/posts');    // + 1 сек
  const comments = await fetch('/api/comments'); // + 1 сек
  // Итого: 3 секунды
}

//  Параллельно (быстро!)
async function fetchAll() {
  const [users, posts, comments] = await Promise.all([
    fetch('/api/users'),
    fetch('/api/posts'),
    fetch('/api/comments')
  ]);
  // Итого: ~1 секунда (самый медленный)
}

//  Или запустить, потом собрать
async function fetchAll() {
  // Запускаем ВСЕ сразу
  const usersPromise = fetch('/api/users');
  const postsPromise = fetch('/api/posts');
  const commentsPromise = fetch('/api/comments');
  
  // Ждём результаты
  const users = await usersPromise;
  const posts = await postsPromise;
  const comments = await commentsPromise;
}

await в циклах

const ids = [1, 2, 3, 4, 5];

//  Последовательно
for (const id of ids) {
  const user = await fetchUser(id); // Ждёт каждого
  console.log(user);
}

//  Параллельно
const users = await Promise.all(
  ids.map(id => fetchUser(id))
);
console.log(users);

//  Параллельно с обработкой каждого
await Promise.all(ids.map(async (id) => {
  const user = await fetchUser(id);
  await processUser(user);
}));

Обработка ошибок

try/catch с async/await

async function fetchData() {
  try {
    const response = await fetch('/api/data');
    
    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }
    
    const data = await response.json();
    return data;
  } catch (error) {
    if (error.name === 'TypeError') {
      console.error('Сетевая ошибка:', error.message);
    } else {
      console.error('Ошибка:', error.message);
    }
    throw error; // Пробрасываем дальше
  } finally {
    console.log('Запрос завершён');
  }
}

Обработка ошибок в Promise.all

// Promise.all падает при ПЕРВОЙ ошибке
try {
  const results = await Promise.all([
    fetch('/api/1'),
    fetch('/api/might-fail'),
    fetch('/api/3')
  ]);
} catch (error) {
  // Сработает если ЛЮБОЙ запрос упадёт
}

// Promise.allSettled не падает
const results = await Promise.allSettled([
  fetch('/api/1'),
  fetch('/api/might-fail'),
  fetch('/api/3')
]);

results.forEach((result, index) => {
  if (result.status === 'fulfilled') {
    console.log(`Запрос ${index}: успех`, result.value);
  } else {
    console.error(`Запрос ${index}: ошибка`, result.reason);
  }
});

Глобальная обработка

// Необработанные Promise rejections
window.addEventListener('unhandledrejection', (event) => {
  console.error('Необработанная ошибка Promise:', event.reason);
  // Отправить в систему логирования
  sendToErrorTracking(event.reason);
});

// Глобальный обработчик ошибок
window.addEventListener('error', (event) => {
  console.error('Глобальная ошибка:', event.error);
});

Fetch API

Базовое использование

// GET запрос
const response = await fetch('https://api.example.com/users');
const users = await response.json();

// POST запрос
const response = await fetch('https://api.example.com/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'Иван',
    email: 'ivan@example.com'
  })
});

// Проверка статуса
if (!response.ok) {
  throw new Error(`HTTP error! Status: ${response.status}`);
}

const newUser = await response.json();

Объект Response

const response = await fetch('/api/data');

// Свойства
response.ok;         // true если статус 200-299
response.status;     // 200, 404, 500...
response.statusText; // "OK", "Not Found"...
response.headers;    // Headers объект
response.url;        // URL запроса
response.type;       // "basic", "cors", "opaque"

// Методы чтения body (можно вызвать только ОДИН раз!)
const json = await response.json();   // JSON → Object
const text = await response.text();   // String
const blob = await response.blob();   // Blob (файлы)
const buffer = await response.arrayBuffer(); // Binary
const formData = await response.formData();  // FormData

// Клонирование для многократного чтения
const clone = response.clone();
const json1 = await response.json();
const json2 = await clone.json();

Headers

// Чтение заголовков ответа
response.headers.get('Content-Type');
response.headers.has('Authorization');

for (const [key, value] of response.headers) {
  console.log(`${key}: ${value}`);
}

// Создание заголовков для запроса
const headers = new Headers();
headers.append('Content-Type', 'application/json');
headers.append('Authorization', 'Bearer token123');

// Или объектом
const headers = {
  'Content-Type': 'application/json',
  'Authorization': 'Bearer token123'
};

Опции fetch

const response = await fetch(url, {
  method: 'POST',           // GET, POST, PUT, DELETE, PATCH
  headers: { ... },
  body: JSON.stringify(data),
  
  mode: 'cors',             // cors, no-cors, same-origin
  credentials: 'include',   // include, same-origin, omit
  cache: 'no-cache',        // default, no-cache, reload, force-cache
  redirect: 'follow',       // follow, error, manual
  referrerPolicy: 'no-referrer',
  
  signal: abortController.signal  // Для отмены
});

Работа с REST API

CRUD операции

const API_URL = 'https://api.example.com';

// CREATE (POST)
async function createUser(userData) {
  const response = await fetch(`${API_URL}/users`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(userData)
  });
  return response.json();
}

// READ (GET)
async function getUser(id) {
  const response = await fetch(`${API_URL}/users/${id}`);
  return response.json();
}

async function getUsers(params = {}) {
  const query = new URLSearchParams(params).toString();
  const response = await fetch(`${API_URL}/users?${query}`);
  return response.json();
}

// UPDATE (PUT/PATCH)
async function updateUser(id, userData) {
  const response = await fetch(`${API_URL}/users/${id}`, {
    method: 'PATCH',  // или PUT для полной замены
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(userData)
  });
  return response.json();
}

// DELETE
async function deleteUser(id) {
  const response = await fetch(`${API_URL}/users/${id}`, {
    method: 'DELETE'
  });
  return response.ok;
}

API клиент

class ApiClient {
  constructor(baseURL, options = {}) {
    this.baseURL = baseURL;
    this.defaultHeaders = {
      'Content-Type': 'application/json',
      ...options.headers
    };
  }
  
  async request(endpoint, options = {}) {
    const url = `${this.baseURL}${endpoint}`;
    const config = {
      ...options,
      headers: {
        ...this.defaultHeaders,
        ...options.headers
      }
    };
    
    if (config.body && typeof config.body === 'object') {
      config.body = JSON.stringify(config.body);
    }
    
    const response = await fetch(url, config);
    
    if (!response.ok) {
      const error = await response.json().catch(() => ({}));
      throw new ApiError(response.status, error.message || 'API Error');
    }
    
    return response.json();
  }
  
  get(endpoint, params) {
    const query = params ? `?${new URLSearchParams(params)}` : '';
    return this.request(`${endpoint}${query}`);
  }
  
  post(endpoint, data) {
    return this.request(endpoint, { method: 'POST', body: data });
  }
  
  put(endpoint, data) {
    return this.request(endpoint, { method: 'PUT', body: data });
  }
  
  patch(endpoint, data) {
    return this.request(endpoint, { method: 'PATCH', body: data });
  }
  
  delete(endpoint) {
    return this.request(endpoint, { method: 'DELETE' });
  }
}

// Использование
const api = new ApiClient('https://api.example.com');
const users = await api.get('/users', { page: 1, limit: 10 });
const newUser = await api.post('/users', { name: 'Иван' });

Продвинутый Fetch

Отмена запроса (AbortController)

const controller = new AbortController();

// Запрос с возможностью отмены
fetch('/api/data', { signal: controller.signal })
  .then(response => response.json())
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('Запрос отменён');
    }
  });

// Отмена через 5 секунд (timeout)
setTimeout(() => controller.abort(), 5000);

// Или по действию пользователя
button.addEventListener('click', () => controller.abort());

// Timeout helper
async function fetchWithTimeout(url, options = {}, timeout = 5000) {
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), timeout);
  
  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal
    });
    return response;
  } finally {
    clearTimeout(id);
  }
}

Загрузка файлов

// Загрузка файла
async function uploadFile(file) {
  const formData = new FormData();
  formData.append('file', file);
  formData.append('name', file.name);
  
  const response = await fetch('/api/upload', {
    method: 'POST',
    body: formData  // Не устанавливайте Content-Type!
  });
  
  return response.json();
}

// С прогрессом (через XMLHttpRequest)
function uploadWithProgress(file, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    
    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable) {
        const percent = (e.loaded / e.total) * 100;
        onProgress(percent);
      }
    });
    
    xhr.addEventListener('load', () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(JSON.parse(xhr.responseText));
      } else {
        reject(new Error(`HTTP ${xhr.status}`));
      }
    });
    
    xhr.addEventListener('error', () => reject(new Error('Network error')));
    
    const formData = new FormData();
    formData.append('file', file);
    
    xhr.open('POST', '/api/upload');
    xhr.send(formData);
  });
}

Скачивание файлов

async function downloadFile(url, filename) {
  const response = await fetch(url);
  const blob = await response.blob();
  
  // Создаём ссылку для скачивания
  const link = document.createElement('a');
  link.href = URL.createObjectURL(blob);
  link.download = filename;
  link.click();
  
  // Освобождаем память
  URL.revokeObjectURL(link.href);
}

Retry логика

async function fetchWithRetry(url, options = {}, retries = 3, delay = 1000) {
  for (let i = 0; i < retries; i++) {
    try {
      const response = await fetch(url, options);
      if (response.ok) return response;
      
      // Retry только для определённых статусов
      if (response.status >= 500) {
        throw new Error(`Server error: ${response.status}`);
      }
      
      return response; // 4xx — не retry
    } catch (error) {
      if (i === retries - 1) throw error;
      
      console.log(`Попытка ${i + 1} не удалась, повтор через ${delay}ms`);
      await new Promise(r => setTimeout(r, delay));
      delay *= 2; // Exponential backoff
    }
  }
}

Параллельные запросы

Promise.all

// Загрузка нескольких ресурсов одновременно
async function loadDashboard() {
  const [user, orders, notifications] = await Promise.all([
    fetch('/api/user').then(r => r.json()),
    fetch('/api/orders').then(r => r.json()),
    fetch('/api/notifications').then(r => r.json())
  ]);
  
  return { user, orders, notifications };
}

// С обработкой ошибок для каждого
async function loadDashboardSafe() {
  const results = await Promise.allSettled([
    fetch('/api/user').then(r => r.json()),
    fetch('/api/orders').then(r => r.json()),
    fetch('/api/notifications').then(r => r.json())
  ]);
  
  return {
    user: results[0].status === 'fulfilled' ? results[0].value : null,
    orders: results[1].status === 'fulfilled' ? results[1].value : [],
    notifications: results[2].status === 'fulfilled' ? results[2].value : []
  };
}

Ограничение параллельности

// Выполнить массив промисов с ограничением
async function parallelLimit(tasks, limit) {
  const results = [];
  const executing = [];
  
  for (const task of tasks) {
    const p = Promise.resolve().then(() => task());
    results.push(p);
    
    if (limit <= tasks.length) {
      const e = p.then(() => executing.splice(executing.indexOf(e), 1));
      executing.push(e);
      
      if (executing.length >= limit) {
        await Promise.race(executing);
      }
    }
  }
  
  return Promise.all(results);
}

// Использование
const urls = [...]; // 100 URL
const results = await parallelLimit(
  urls.map(url => () => fetch(url).then(r => r.json())),
  5  // Максимум 5 одновременных запросов
);

Кэширование

Простой кэш в памяти

const cache = new Map();

async function fetchWithCache(url, ttl = 60000) {
  const cached = cache.get(url);
  
  if (cached && Date.now() - cached.timestamp < ttl) {
    return cached.data;
  }
  
  const response = await fetch(url);
  const data = await response.json();
  
  cache.set(url, { data, timestamp: Date.now() });
  
  return data;
}

// Очистка старых записей
function clearExpiredCache(ttl) {
  const now = Date.now();
  for (const [key, value] of cache) {
    if (now - value.timestamp > ttl) {
      cache.delete(key);
    }
  }
}

Cache API

// Открыть кэш
const cache = await caches.open('api-cache-v1');

// Добавить в кэш
await cache.put('/api/data', response);
await cache.add('/api/data'); // fetch + put

// Получить из кэша
const cachedResponse = await cache.match('/api/data');

// Стратегия: Cache First, Network Fallback
async function cacheFirst(url) {
  const cachedResponse = await caches.match(url);
  if (cachedResponse) return cachedResponse;
  
  const response = await fetch(url);
  const cache = await caches.open('api-cache');
  cache.put(url, response.clone());
  
  return response;
}

// Стратегия: Network First, Cache Fallback
async function networkFirst(url) {
  try {
    const response = await fetch(url);
    const cache = await caches.open('api-cache');
    cache.put(url, response.clone());
    return response;
  } catch {
    return caches.match(url);
  }
}

WebSockets

Базовое использование

const ws = new WebSocket('wss://api.example.com/ws');

// События
ws.addEventListener('open', () => {
  console.log('Соединение открыто');
  ws.send(JSON.stringify({ type: 'subscribe', channel: 'updates' }));
});

ws.addEventListener('message', (event) => {
  const data = JSON.parse(event.data);
  console.log('Получено:', data);
});

ws.addEventListener('close', (event) => {
  console.log(`Соединение закрыто: ${event.code} ${event.reason}`);
});

ws.addEventListener('error', (error) => {
  console.error('Ошибка WebSocket:', error);
});

// Отправка сообщений
ws.send(JSON.stringify({ type: 'message', text: 'Привет!' }));

// Закрытие
ws.close(1000, 'Нормальное закрытие');

WebSocket с реконнектом

class ReconnectingWebSocket {
  constructor(url, options = {}) {
    this.url = url;
    this.reconnectInterval = options.reconnectInterval || 1000;
    this.maxReconnectInterval = options.maxReconnectInterval || 30000;
    this.handlers = { message: [], open: [], close: [], error: [] };
    this.connect();
  }
  
  connect() {
    this.ws = new WebSocket(this.url);
    
    this.ws.onopen = () => {
      this.reconnectInterval = 1000;
      this.handlers.open.forEach(h => h());
    };
    
    this.ws.onmessage = (e) => {
      this.handlers.message.forEach(h => h(JSON.parse(e.data)));
    };
    
    this.ws.onclose = () => {
      this.handlers.close.forEach(h => h());
      this.reconnect();
    };
    
    this.ws.onerror = (e) => {
      this.handlers.error.forEach(h => h(e));
    };
  }
  
  reconnect() {
    setTimeout(() => {
      console.log('Переподключение...');
      this.connect();
      this.reconnectInterval = Math.min(
        this.reconnectInterval * 2,
        this.maxReconnectInterval
      );
    }, this.reconnectInterval);
  }
  
  on(event, handler) {
    this.handlers[event].push(handler);
  }
  
  send(data) {
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data));
    }
  }
}

// Использование
const ws = new ReconnectingWebSocket('wss://api.example.com/ws');
ws.on('message', (data) => console.log(data));

Реальные проекты

Поиск с debounce

const searchInput = document.getElementById('search');
const resultsContainer = document.getElementById('results');

let controller = null;

const search = debounce(async (query) => {
  // Отменяем предыдущий запрос
  if (controller) controller.abort();
  controller = new AbortController();
  
  if (!query.trim()) {
    resultsContainer.innerHTML = '';
    return;
  }
  
  try {
    const response = await fetch(
      `/api/search?q=${encodeURIComponent(query)}`,
      { signal: controller.signal }
    );
    
    const results = await response.json();
    renderResults(results);
  } catch (error) {
    if (error.name !== 'AbortError') {
      console.error('Ошибка поиска:', error);
    }
  }
}, 300);

searchInput.addEventListener('input', (e) => search(e.target.value));

Бесконечный скролл

let page = 1;
let loading = false;
let hasMore = true;

const observer = new IntersectionObserver(async (entries) => {
  if (entries[0].isIntersecting && !loading && hasMore) {
    loading = true;
    showLoader();
    
    try {
      const items = await fetchItems(page);
      
      if (items.length === 0) {
        hasMore = false;
      } else {
        appendItems(items);
        page++;
      }
    } catch (error) {
      console.error('Ошибка загрузки:', error);
    } finally {
      loading = false;
      hideLoader();
    }
  }
});

// Наблюдаем за элементом в конце списка
observer.observe(document.querySelector('.load-trigger'));

Итоговый проект

Создайте приложение новостей с:

  • Загрузкой новостей из публичного API
  • Поиском с debounce
  • Бесконечным скроллом
  • Кэшированием
  • Обработкой ошибок и retry
  • Индикаторами загрузки

Настройки

Тема