⚡ JavaScript

JavaScript Asíncrono

Асинхронный JavaScript — это одна из самых важных и уникальных особенностей языка. Почему загрузка данных не "замораживает" страницу? Как работают анимации и таймеры? Почему нужны Promise и async/await? В этом уроке мы разберём всё это — от принципа работы до практического использования fetch API.

Почему JavaScript однопоточный?

JavaScript — однопоточный язык. Это значит, что он может делать только одно дело одновременно. Аналогия: один кассир в магазине. Пока он обслуживает одного покупателя — остальные ждут в очереди.

Если JavaScript мог бы делать только одно за раз — сайты бы "замерзали" при любом запросе к серверу. Нажал кнопку "загрузить" — страница висит и не реагирует, пока не загрузятся данные. Ужасно!

Решение — асинхронность. Аналогия с рестораном быстрого питания: кассир принимает ваш заказ и отдаёт вам номерок. Пока повар готовит еду — кассир обслуживает следующих. Когда еда готова — вас вызывают по номерку. Кассир (JavaScript) не стоит и не ждёт у плиты — он продолжает работать!

Событийный цикл (Event Loop)

Чтобы понять асинхронность, нужно понять, как JavaScript управляет задачами:

console.log("1. Начало");

setTimeout(function() {
  console.log("3. setTimeout (через 0мс)");
}, 0); // Даже с 0 мс — выполнится после синхронного кода!

console.log("2. Конец синхронного кода");

// Вывод:
// 1. Начало
// 2. Конец синхронного кода
// 3. setTimeout (через 0мс)

Вот что происходит: JavaScript сначала выполняет весь синхронный код сверху вниз. setTimeout помещается в очередь задач ("отложить на потом"). После завершения синхронного кода — берётся первое задание из очереди. Это и есть Event Loop — цикл событий.

console.log("Старт");

// Имитация запроса к серверу (занимает 2 секунды):
setTimeout(() => console.log("Данные с сервера получены!"), 2000);

// Пока ждём ответ — делаем другие дела:
console.log("Делаем что-то ещё, пока ждём...");
console.log("И ещё что-то...");
console.log("Сайт работает, не замёрз!");

// Через 2 секунды: "Данные с сервера получены!"

Callback-функции и Callback Hell

Первый способ работы с асинхронным кодом — колбэки. Мы уже знаем их из прошлых уроков. Идея: когда асинхронная операция завершится, вызови эту функцию (позвони обратно).

// Простой колбэк — загрузить данные пользователя:
function загрузитьПользователя(id, колбэк) {
  setTimeout(function() {
    // Имитация получения данных с сервера:
    let пользователь = { id: id, имя: "Аня", возраст: 20 };
    колбэк(пользователь);
  }, 1000);
}

загрузитьПользователя(1, function(пользователь) {
  console.log(`Загружен: ${пользователь.имя}`);
});

Пока всё хорошо. Но что если нужно сделать несколько последовательных запросов? Загрузить пользователя, потом его заказы, потом детали заказа... Получается Callback Hell (ад колбэков):

// Callback Hell — пирамида из ада:
загрузитьПользователя(1, function(пользователь) {
  console.log("Пользователь:", пользователь.имя);
  загрузитьЗаказы(пользователь.id, function(заказы) {
    console.log("Заказов:", заказы.length);
    загрузитьТовары(заказы[0].id, function(товары) {
      console.log("Товаров в заказе:", товары.length);
      проверитьНаличие(товары[0].id, function(наличие) {
        console.log("В наличии:", наличие);
        // И ещё глубже... и глубже...
        // Код уходит вправо, читать невозможно, поддерживать невозможно
      });
    });
  });
});

Это называют "пирамидой смерти" — каждый уровень вложенности сдвигает код вправо, код становится нечитаемым, и обработка ошибок превращается в кошмар. Именно эту проблему решили Promise.

Promise: "Я обещаю вернуть результат"

Promise (промис) — это объект, представляющий результат асинхронной операции. Аналогия: вы заказали пиццу. Курьер дал вам "квиток о заказе" (это промис). Пиццы ещё нет, но у вас есть обещание. Промис может быть в трёх состояниях:

  • pending (ожидание) — пицца ещё готовится
  • fulfilled (выполнен) — пиццу привезли, всё отлично
  • rejected (отклонён) — ресторан закрылся, заказ отменён
// Создание Promise:
let мойПромис = new Promise(function(resolve, reject) {
  // Асинхронная операция (имитируем запрос):
  setTimeout(function() {
    let успех = true; // Представим, что запрос успешен

    if (успех) {
      resolve("Данные получены!"); // Успех — вызвать resolve с результатом
    } else {
      reject("Ошибка соединения"); // Ошибка — вызвать reject с причиной
    }
  }, 2000);
});

// Использование Promise:
мойПромис
  .then(function(результат) {
    console.log("Успех:", результат); // "Данные получены!"
  })
  .catch(function(ошибка) {
    console.log("Ошибка:", ошибка); // Если reject
  })
  .finally(function() {
    console.log("Выполнилось в любом случае"); // И при успехе, и при ошибке
  });

Разбор:

  • new Promise((resolve, reject) => {...}) — создаём промис. Внутри выполняется асинхронная работа.
  • resolve(данные) — сигнал "всё хорошо, вот результат"
  • reject(ошибка) — сигнал "что-то пошло не так"
  • .then(колбэк) — что делать при успехе
  • .catch(колбэк) — что делать при ошибке
  • .finally(колбэк) — выполнится всегда

Цепочки промисов

Главное преимущество промисов — они решают проблему callback hell. Вместо вложенных колбэков — читаемая цепочка:

function загрузитьПользователя(id) {
  return new Promise((resolve) => {
    setTimeout(() => resolve({ id: id, имя: "Аня" }), 500);
  });
}

function загрузитьЗаказы(userId) {
  return new Promise((resolve) => {
    setTimeout(() => resolve([{ id: 1, сумма: 500 }, { id: 2, сумма: 1200 }]), 500);
  });
}

function загрузитьДетали(orderId) {
  return new Promise((resolve) => {
    setTimeout(() => resolve({ товар: "Ноутбук", количество: 1 }), 500);
  });
}

// Цепочка промисов — читабельно!
загрузитьПользователя(1)
  .then(пользователь => {
    console.log("Пользователь:", пользователь.имя);
    return загрузитьЗаказы(пользователь.id);
  })
  .then(заказы => {
    console.log("Заказов:", заказы.length);
    return загрузитьДетали(заказы[0].id);
  })
  .then(детали => {
    console.log("Детали:", детали.товар);
  })
  .catch(ошибка => {
    console.error("Что-то пошло не так:", ошибка);
  });

Promise.all и Promise.race

// Promise.all — ждёт ВСЕ промисы параллельно:
let запрос1 = new Promise(r => setTimeout(() => r("Данные 1"), 1000));
let запрос2 = new Promise(r => setTimeout(() => r("Данные 2"), 2000));
let запрос3 = new Promise(r => setTimeout(() => r("Данные 3"), 500));

Promise.all([запрос1, запрос2, запрос3])
  .then(результаты => {
    console.log(результаты); // ["Данные 1", "Данные 2", "Данные 3"]
    // Ждёт самый медленный (2 секунды), потом возвращает все вместе
  });
// Если хоть один reject — всё Promise.all идёт в catch!

// Promise.race — возвращает результат первого завершившегося:
Promise.race([запрос1, запрос2, запрос3])
  .then(результат => {
    console.log(результат); // "Данные 3" (он завершился первым за 500мс)
  });

// Promise.allSettled — ждёт все, не прерывается на ошибках:
Promise.allSettled([запрос1, запрос2, запрос3])
  .then(результаты => {
    результаты.forEach(р => {
      if (р.status === "fulfilled") console.log("Успех:", р.value);
      else console.log("Ошибка:", р.reason);
    });
  });

async/await: сахар поверх промисов

async/await — это синтаксический сахар над промисами, делающий асинхронный код похожим на синхронный. Проще говоря: вместо .then().then() — обычный код сверху вниз, только с await перед асинхронными операциями.

// Функция с async — всегда возвращает промис:
async function получитьДанные() {
  // await — "подождать" пока промис разрешится, потом продолжить:
  let пользователь = await загрузитьПользователя(1);
  console.log("Пользователь:", пользователь.имя);

  let заказы = await загрузитьЗаказы(пользователь.id);
  console.log("Заказов:", заказы.length);

  let детали = await загрузитьДетали(заказы[0].id);
  console.log("Детали:", детали.товар);

  return детали; // async функция возвращает промис с этим значением
}

// Вызов:
получитьДанные();
// или:
получитьДанные().then(детали => console.log("Готово!", детали));

Сравнение: то же самое через .then() занимало много строк с вложенностями. С async/await — читается как синхронный код сверху вниз!

Обработка ошибок в async/await

async function безопасныйЗапрос(url) {
  try {
    // Пробуем выполнить асинхронную операцию:
    let ответ = await fetch(url);

    if (!ответ.ok) {
      throw new Error(`HTTP ошибка: ${ответ.status}`);
    }

    let данные = await ответ.json();
    return данные;
  } catch (ошибка) {
    // Ловим любые ошибки — и сетевые, и наши throw:
    console.error("Ошибка запроса:", ошибка.message);
    return null; // Возвращаем null при ошибке
  } finally {
    console.log("Запрос завершён (с любым исходом)");
  }
}

async function main() {
  let данные = await безопасныйЗапрос("https://api.example.com/users");
  if (данные) {
    console.log("Получено пользователей:", данные.length);
  } else {
    console.log("Не удалось загрузить данные");
  }
}

main();

fetch API: запросы к серверу

fetch — это встроенная функция JavaScript для выполнения HTTP-запросов (получения данных с серверов). Возвращает промис.

// Базовый GET запрос (получить данные):
async function загрузитьПользователей() {
  try {
    // JSONPlaceholder — бесплатный тестовый API:
    let ответ = await fetch("https://jsonplaceholder.typicode.com/users");

    // Проверяем статус ответа:
    if (!ответ.ok) {
      throw new Error(`Ошибка: ${ответ.status}`);
    }

    // Парсим JSON:
    let пользователи = await ответ.json();

    console.log(`Загружено ${пользователи.length} пользователей:`);
    пользователи.slice(0, 3).forEach(п => {
      console.log(`  - ${п.name} (${п.email})`);
    });
  } catch (ошибка) {
    console.error("Ошибка загрузки:", ошибка.message);
  }
}

загрузитьПользователей();

// POST запрос (отправить данные):
async function создатьПост(заголовок, текст) {
  try {
    let ответ = await fetch("https://jsonplaceholder.typicode.com/posts", {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        title: заголовок,
        body: текст,
        userId: 1
      })
    });

    let новыйПост = await ответ.json();
    console.log("Пост создан! ID:", новыйПост.id);
    return новыйПост;
  } catch (ошибка) {
    console.error("Ошибка создания поста:", ошибка.message);
  }
}

создатьПост("Мой первый пост", "Это текст поста");

Разбор fetch: первый аргумент — URL. Второй (необязательный) — настройки: метод (GET/POST/PUT/DELETE), заголовки, тело запроса. ответ.json() — тоже промис, нужно ждать. ответ.ok — true если статус 200-299.

Реальный пример: погодное приложение

// Приложение для получения погоды через Open-Meteo API (бесплатный, без ключа):
async function получитьПогоду(город, широта, долгота) {
  const url = `https://api.open-meteo.com/v1/forecast?latitude=${широта}&longitude=${долгота}¤t_weather=true`;

  try {
    console.log(`Загружаю погоду для ${город}...`);
    const ответ = await fetch(url);

    if (!ответ.ok) throw new Error("Не удалось получить погоду");

    const данные = await ответ.json();
    const погода = данные.current_weather;

    const описаниеКода = {
      0: "Ясно", 1: "Почти ясно", 2: "Переменная облачность",
      3: "Пасмурно", 61: "Дождь", 71: "Снег", 95: "Гроза"
    };

    const описание = описаниеКода[погода.weathercode] || "Неизвестно";

    console.log(`
🌤 Погода в ${город}:`);
    console.log(`   Температура: ${погода.temperature}°C`);
    console.log(`   Ветер: ${погода.windspeed} км/ч`);
    console.log(`   Описание: ${описание}`);

    return погода;
  } catch (ошибка) {
    console.error(`❌ Ошибка для ${город}:`, ошибка.message);
    return null;
  }
}

// Параллельная загрузка погоды в нескольких городах:
async function погодаНескольких() {
  const города = [
    { имя: "Москва", lat: 55.75, lon: 37.62 },
    { имя: "Санкт-Петербург", lat: 59.93, lon: 30.32 },
    { имя: "Новосибирск", lat: 55.02, lon: 82.92 }
  ];

  // Запускаем все запросы параллельно:
  const результаты = await Promise.all(
    города.map(г => получитьПогоду(г.имя, г.lat, г.lon))
  );

  console.log("
✅ Все данные загружены!");
}

погодаНескольких();

async/await vs Promise.then — когда что использовать?

СитуацияРекомендация
Последовательные операцииasync/await — читабельнее
Параллельные операцииPromise.all — эффективнее
Обработка ошибокtry/catch с async/await — удобнее
Простой одиночный промис.then().catch() — лаконично
Работа с событиямиКолбэки — традиционный подход

Типичные ошибки новичков

  • Забыть await. let данные = fetch(url) — вы получите промис, а не данные! Правильно: let данные = await fetch(url).
  • await вне async функции. await работает только внутри async функций. На верхнем уровне (в современных браузерах/Node.js) можно, но внутри обычной функции — ошибка.
  • Не обрабатывать ошибки. Всегда оборачивайте await в try/catch. Сетевые ошибки случаются — сервер недоступен, интернет пропал.
  • Последовательный await вместо параллельного. Если две операции независимы — запускайте их параллельно через Promise.all, не последовательно.
  • Не проверять ответ fetch. fetch не выбрасывает ошибку при статусах 404, 500 и т.д. Всегда проверяйте response.ok.

Итого

В этом уроке мы узнали:

  1. JavaScript однопоточный, но использует Event Loop для асинхронности — код не замерзает при ожидании.
  2. Callback — первый подход к асинхронности, приводит к callback hell при вложенности.
  3. Promise — объект, представляющий будущий результат. Три состояния: pending, fulfilled, rejected.
  4. .then()/.catch()/.finally() — обработка результатов промиса в цепочке.
  5. Promise.all — запустить все промисы параллельно, ждать завершения всех.
  6. async/await — синтаксический сахар над промисами. Делает асинхронный код похожим на синхронный.
  7. try/catch — обработка ошибок в async функциях.
  8. fetch API — встроенный инструмент для HTTP-запросов, возвращает промис.

Поздравляем! Вы прошли все шесть уроков по основам JavaScript. Теперь у вас есть фундамент для изучения фреймворков (React, Vue), серверной части (Node.js, Express) и всего остального увлекательного мира JavaScript-разработки!