Глиф Учебники

Урок 10. Асинхронность: setTimeout, промисы, async/await и fetch

Курс «JavaScript» · урок 10 из 10 · 5 минут чтения

Некоторые действия занимают время: подождать секунду, скачать данные с сервера, прочитать большой файл. Если бы программа на это время замирала, страница перестала бы отвечать на нажатия. Поэтому в JavaScript такие действия асинхронные: программа запускает действие, идёт дальше, а результат обрабатывает потом, когда он будет готов.

setTimeout: выполнить позже

setTimeout(функция, задержка) вызывает функцию через указанное число миллисекунд (1000 мс — одна секунда):

JavaScript
console.log("Первый");

setTimeout(() => {
  console.log("Третий — через секунду");
}, 1000);

console.log("Второй");
Вывод
Первый
Второй
Третий — через секунду

«Второй» выводится раньше «Третьего», хотя стоит ниже в коде: setTimeout не ждёт, а только ставит функцию в очередь и сразу возвращает управление. Даже с задержкой 0 функция выполнится только после того, как закончится весь текущий код.

Так устроен цикл событий (event loop): JavaScript выполняет код в одном потоке, по одной задаче. Таймеры, ответы сервера и щелчки мыши встают в очередь, и их обработчики запускаются по одному, когда поток свободен.

поток таймер вывод console.log setTimeout console.log поток свободен функция ждёт 1000 мс в очередь Первый Второй Третий — через секунду время
setTimeout только отдаёт функцию таймеру, и поток сразу выводит «Второй»; через 1000 мс функция встаёт в очередь и выполняется, когда поток свободен, — выводится «Третий — через секунду».

setTimeout возвращает номер таймера; clearTimeout(номер) отменяет вызов, если он ещё не случился. Повторять действие через равные промежутки умеет setInterval — о нём в справке Как сделать таймер.

Промисы

Колбэки удобны для одного действия, но когда действий несколько и каждое ждёт предыдущего, код уходит вложенными функциями вправо и плохо читается. Поэтому появились промисы (Promise — «обещание»).

Промис — объект, который представляет результат, которого ещё нет. У него три состояния:

Многие функции браузера и Node.js сразу возвращают промис — например, fetch. Свой промис создают так:

JavaScript
function wait(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

wait(500).then(() => {
  console.log("Прошло полсекунды");
});

console.log("Ожидание…");
Вывод
Ожидание…
Прошло полсекунды

new Promise получает функцию с параметрами resolve и reject. Вызов resolve(значение) выполняет промис, reject(ошибка) — отклоняет. Метод then назначает функцию, которая получит результат, catch — функцию для ошибки, finally — функцию, которая выполнится в любом случае.

ожидание pending выполнен fulfilled resolve(значение) отклонён rejected reject(ошибка) then получит результат finally в любом случае catch получит ошибку
Промис начинается в ожидании; resolve выполняет его, и результат получает функция из then, а reject отклоняет, и ошибка уходит в catch; функция из finally выполняется в обоих случаях.
JavaScript
function divide(a, b) {
  return new Promise((resolve, reject) => {
    if (b === 0) {
      reject(new Error("делить на ноль нельзя"));
    } else {
      resolve(a / b);
    }
  });
}

divide(1, 0)
  .then((result) => console.log("Результат:", result))
  .catch((err) => console.log("Ошибка:", err.message))
  .finally(() => console.log("Готово"));
Вывод
Ошибка: делить на ноль нельзя
Готово

new Error("текст") создаёт объект ошибки; текст из него читают через err.message.

async и await

Цепочки then читаются лучше колбэков, но ещё проще запись через async и await. Она выглядит как обычный последовательный код:

JavaScript
function wait(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function main() {
  console.log("Старт");
  await wait(300);
  console.log("Прошло 0,3 секунды");
  await wait(300);
  console.log("Прошло ещё 0,3 секунды");
}

main();
Вывод
Старт
Прошло 0,3 секунды
Прошло ещё 0,3 секунды

Ошибки: try и catch

Ошибка в обычном коде останавливает программу. Чтобы перехватить её и обработать, код ставят в блок try, а обработку — в блок catch:

JavaScript
try {
  const data = JSON.parse("не JSON");
  console.log(data);
} catch (err) {
  console.log("Ошибка разбора:", err.name);
}
console.log("Программа продолжает работу");
Вывод
Ошибка разбора: SyntaxError
Программа продолжает работу

Если в блоке try случилась ошибка, остаток блока пропускается, а выполняется catch; в его параметре — объект ошибки. С await всё так же: отклонённый промис внутри try попадает в catch.

JavaScript
function failLater() {
  return new Promise((resolve, reject) => {
    setTimeout(() => reject(new Error("сервер не ответил")), 100);
  });
}

async function main() {
  try {
    await failLater();
    console.log("Сюда не дойдёт");
  } catch (err) {
    console.log("Поймано:", err.message);
  }
}

main();
Вывод
Поймано: сервер не ответил

Несколько действий сразу: Promise.all

Если действия не зависят друг от друга, их запускают одновременно, а ждут все вместе — через Promise.all:

JavaScript
function delay(value, ms) {
  return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}

async function main() {
  const results = await Promise.all([
    delay("А", 300),
    delay("Б", 100),
    delay("В", 200),
  ]);
  console.log(results);
}

main();
Вывод
[ 'А', 'Б', 'В' ]

Все три таймера идут одновременно, поэтому ждать приходится 0,3 секунды, а не 0,6. Результаты стоят в том же порядке, что и промисы, — неважно, какой закончился первым. Если отклонится хотя бы один промис, Promise.all тоже отклоняется.

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

fetch(адрес) отправляет HTTP-запрос и возвращает промис с ответом. Он есть во всех современных браузерах, а в Node.js — начиная с версии 18, без установки пакетов. Вот программа для Node.js: она скачивает с сайта Node.js список его версий в формате JSON и выводит самую свежую версию LTS (с долгой поддержкой).

JavaScript
async function main() {
  const response = await fetch("https://nodejs.org/dist/index.json");
  if (!response.ok) {
    throw new Error("Сервер ответил кодом " + response.status);
  }
  const versions = await response.json();
  const lts = versions.find((v) => v.lts);
  console.log("Свежая LTS:", lts.version, lts.lts);
}

main().catch((err) => console.log("Ошибка:", err.message));

Программа выводит строку вида Свежая LTS: v… … — номер версии и её кодовое имя. Без интернета будет Ошибка: fetch failed.

Порядок работы с fetch:

  1. await fetch(адрес) — дождаться ответа. В нём уже есть код ответа сервера, но тело ещё не прочитано.
  2. Проверить response.ok — true, если код ответа от 200 до 299. fetch не считает ошибкой ответы 404 или 500: промис выполняется, и только response.ok покажет, что что-то не так. Отклоняется он лишь тогда, когда ответа нет совсем: нет сети, неверный адрес сервера.
  3. await response.json() — прочитать тело и разобрать JSON. Для простого текста — response.text().

throw new Error(...) выбрасывает свою ошибку: дальше код не идёт, а ошибка попадает в ближайший catch.

await fetch(адрес) ответ пришёл response.ok код от 200 до 299? true await response.json() данные false: 404, 500 throw new Error(...) ответа нет: нет сети, неверный адрес catch — обработка ошибки
Ответ с кодом 404 или 500 fetch не считает ошибкой — это видно только по response.ok; при true тело читают через response.json(), а при false и когда ответа нет вовсе, ошибка попадает в catch.

fetch в браузере

На странице тот же код выводит данные в элементы. Пусть рядом со страницей лежит файл data.json с массивом товаров вида [{"name": "Хлеб", "price": 2.1}]:

JavaScript
async function loadProducts() {
  const list = document.querySelector("#products");
  try {
    const response = await fetch("data.json");
    if (!response.ok) {
      throw new Error("код ответа " + response.status);
    }
    const products = await response.json();
    for (const p of products) {
      const item = document.createElement("li");
      item.textContent = `${p.name} — ${p.price} руб.`;
      list.append(item);
    }
  } catch (err) {
    list.textContent = "Не удалось загрузить товары: " + err.message;
  }
}

loadProducts();

Две частые причины, по которым такой код не работает:

Коротко

Проверьте себя

1. В каком порядке появятся строки?
JavaScript
console.log("A");
setTimeout(() => console.log("B"), 0);
console.log("C");
2. Где можно писать await?
3. Что сделает fetch, если сервер ответил кодом 404?
4. Что выведет программа?
JavaScript
const p = new Promise((resolve, reject) => reject(new Error("сбой")));
p.then(() => console.log("успех")).catch((e) => console.log(e.message));
5. Сколько примерно займёт await Promise.all([wait(1000), wait(2000)])?