Асинхронность, fetch и JSON

Зачем:Данные с сервера приходят не сразу, а страница при этом не должна зависать. После урока вы запрашиваете данные через fetch, читаете и пишете JSON, понимаете промисы и async/await и показываете посетителю состояния «загрузка» и «ошибка».

30 минут

Озвучено синтезом речи (Qwen3-TTS), голос не принадлежит человеку.

fetch — функция JavaScript, которая отправляет HTTP-запрос и возвращает промис с ответом. Данные обычно приходят в JSON, текстовом формате объектов и массивов.

Данные, которые приходят не сразу

До сих пор скрипт работал с тем, что уже лежит на странице. Но настоящий сайт часто берёт данные с сервера: список событий, свободное время для записи, статус заказа. И отправляет туда: заявку, отзыв, поиск. Это всегда занимает время: запрос идёт по сети, сервер думает, ответ идёт обратно. Секунду, три, иногда не приходит вовсе.

Если бы скрипт во время этого ожидания просто стоял, страница бы замёрзла: ни прокрутки, ни кликов. Чтобы этого не было, всё, что связано с ожиданием, в JavaScript сделано асинхронным. JavaScript выполняется в одном потоке, то есть делает одно дело за раз. Но ожидание ответа он поручает браузеру («пришлёшь, когда придёт»), занимается остальным, и когда ответ готов, браузер ставит его обработку в очередь, и скрипт доходит до неё в свою очередь. Такую очередь называют циклом событий (event loop), и именно поэтому обработчики из прошлого урока вызываются «когда-нибудь», а не сразу.

Промисы и async/await

Результат, которого пока нет, в JavaScript представлен значением, которое называется промис (promise, «обещание»). Это ящик, в который результат положат позже. У промиса три состояния:

  • pending: ответа ещё нет;
  • fulfilled: пришёл результат;
  • rejected: произошёл сбой.

Раньше такие вещи делали «колбэками»: функцию передавали и просили вызвать, когда будет готово. Если за одним ожиданием шло другое, а за ним третье, получалась лестница из вложенных функций, которую называли адом колбэков. В 2015 году появились промисы, в 2017 новая запись async/await, которая позволяет писать ожидание как обычный код сверху вниз:

async function loadItems() {
  const response = await fetch('/items.json');   // ждём ответ, но страница живая
  const items = await response.json();           // ждём, пока тело дочитается
  return items;
}

Слово await ставится перед промисом и означает: «подожди результата, а потом продолжай». Писать его можно только внутри функции, объявленной с async. Сама такая функция тоже возвращает промис, поэтому её можно ждать в другом месте.

Посмотрите, что происходит со страницей, пока запрос в пути. Нажимайте на счётчик, пока ждёте ответ: в обычном режиме страница живёт, а в «блокирующем» вы увидите, как выглядела бы страница, ждущая ответа без асинхронности.

Пока идёт запрос, страница жива
Страница (основной поток)

Скрипт может заниматься своими делами.

Сеть и сервер

тишина

Промис

ещё не создан

Выберите исход и отправьте запрос.

Проверьте себя: почему страница не зависает, пока fetch ждёт ответ?

Ожидание делает браузер, а не скрипт. Скрипт получает промис и продолжает, а когда ответ готов, его обработка встаёт в очередь событий.

fetch

fetch(адрес) отправляет HTTP-запрос (тот самый, что вы разбирали в уроке про HTTP) и возвращает промис с ответом. Метод по умолчанию GET. Чтобы отправить данные, добавляют параметры:

const response = await fetch('/zayavka', {
  method: 'POST',
  body: new FormData(form)    // данные формы, как их отправил бы браузер
});

Ответ (response) похож на то, что показывала вам вкладка «Сеть»: у него есть status (код), ok (удобное «код от 200 до 299») и методы чтения тела: text(), json() и другие. Читать тело тоже асинхронно, поэтому и перед response.json() стоит await.

Здесь ловушка, на которой спотыкаются почти все. fetch отклоняет промис только при сбое сети: нет связи, адрес не найден, соединение оборвано. Если сервер ответил, пусть даже 404 или 500, это тоже ответ, и промис выполняется успешно. Поэтому после каждого fetch пишут проверку:

if (!response.ok) {
  throw new Error('Сервер ответил кодом ' + response.status);
}

Без неё страница попытается прочитать страницу ошибки как данные и покажет посетителю пустоту или странный текст.

Есть и ограничение безопасности: браузер разрешает странице запрашивать данные у своего сайта. Запрос к чужому сайту пройдёт, только если тот в своих заголовках разрешил (это называют CORS). Если вы видите в консоли красное «blocked by CORS policy», проблема на стороне сервера, а не в вашем коде.

JSON

Сервер и страница обмениваются данными в виде текста, и самый распространённый формат этого текста называется JSON (JavaScript Object Notation). Его придумал Дуглас Крокфорд в начале 2000-х как замену тяжёлому XML, и он оказался настолько простым, что стал стандартом (RFC 8259). По виду он почти совпадает с объектами и массивами JavaScript, которые вы писали в первом уроке:

[
  { "title": "Скатерть", "text": "Лён, ручная обработка края.", "price": "0 000 ₽" },
  { "title": "Фартук", "text": "С карманом.", "price": "" }
]

Но правила строже:

  • имена и строки только в двойных кавычках;
  • допустимы объекты, массивы, строки, числа, true, false и null;
  • нет комментариев и нет запятой после последнего элемента.

Из-за последней запятой падает много скриптов: JavaScript её простит, а JSON нет. Две команды превращают текст и значения друг в друга: JSON.parse(текст) читает JSON в обычное значение, а JSON.stringify(значение) записывает значение в JSON. Метод response.json() делает JSON.parse над телом ответа за вас.

Когда что-то пошло не так

Три разных беды выглядят по-разному:

  1. Сети нет. fetch отклоняется (в консоли TypeError: Failed to fetch), и сработает catch.
  2. Сервер ответил ошибкой. Промис выполнен, response.ok равен false: проверка выше превращает это в такую же ошибку, как и первое.
  3. Ответ не тот. Это не JSON, или в нём нет нужного поля. response.json() сам отклонится, а недостающие поля надо проверять.

Оборачивают всё в try ... catch ... finally. В try идёт то, что может сломаться. В catch попадает ошибка. А блок finally выполняется в любом случае, и там убирают индикатор загрузки, чтобы он не остался висеть вечно после сбоя.

Что видит посетитель, важнее того, что видит консоль. Ему нужны три состояния и честный текст:

  • загрузка: «Загружаем свежий список…» (в области role="status" дикторы сообщат об этом слепым посетителям);
  • успех: данные;
  • ошибка: не «TypeError: Failed to fetch», а «Не удалось обновить список, показан сохранённый. Проверьте связь и повторите», и рядом кнопка «Повторить».

Технические подробности остаются в console.error(error). Кнопку отправки на время запроса выключают (disabled), иначе нетерпеливый посетитель нажмёт дважды, и вы получите две одинаковые заявки.

Практика

Продолжаем страницу эпохи 2014 года. Мы подключим её к «серверу» и к его данным. Сервера у нас, конечно, нет: песочница не пускает настоящие запросы. Поэтому вы вставите в страницу блок, который подменяет fetch и отвечает так, как скажет переключатель «связь»: всё хорошо, медленно, обрыв, сервер сломался, файла нет. Ваш код при этом настоящий: на настоящем сайте вы просто удалите этот блок, и тот же fetch('/items.json') пойдёт на ваш сервер. Блок помечен data-scaffold, поэтому в вашем скачанном сайте и в галерее его не будет.

Вставьте блок целиком в конец body, перед своими скриптами:

  <fieldset data-scaffold id="net-sim">
    <legend>Имитация сервера: какая сейчас связь</legend>
    <label><input type="radio" name="net" value="ok" checked> всё хорошо</label>
    <label><input type="radio" name="net" value="slow"> медленно, три секунды</label>
    <label><input type="radio" name="net" value="offline"> обрыв связи</label>
    <label><input type="radio" name="net" value="error500"> сервер сломался (500)</label>
    <label><input type="radio" name="net" value="missing404"> файла нет (404)</label>
  </fieldset>
  <script type="module" data-scaffold>
    // ИМИТАЦИЯ СЕРВЕРА. Песочница не пускает настоящие запросы, поэтому fetch
    // на этой странице подменён: он отвечает так, как выбрано выше.
    // На настоящем сайте этого блока нет, а адреса ведут на ваш сервер.
    const snapshot = [...document.querySelectorAll('#offer .card')].map((card) => ({
      title: card.querySelector('h3').textContent,
      text: card.querySelector('p').textContent,
      price: card.querySelector('.price')?.textContent ?? ''
    }));
    const reply = (data, status = 200) =>
      new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } });
    const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

    window.fetch = async (url, options = {}) => {
      const mode = document.querySelector('input[name="net"]:checked').value;
      await pause(mode === 'slow' ? 3000 : 600);
      if (mode === 'offline') throw new TypeError('Failed to fetch');
      if (mode === 'error500') return reply({ error: 'Сервер сломался' }, 500);
      if (mode === 'missing404') return reply({ error: 'Не найдено' }, 404);
      const path = new URL(url, 'https://site.example/').pathname;
      if (path === '/items.json') {
        return reply([...snapshot, { title: 'Новый пункт с сервера', text: 'Его нет в разметке: он пришёл из items.json.', price: '' }]);
      }
      if (path === '/zayavka' && options.method === 'POST') return reply({ id: 17 }, 201);
      return reply({ error: 'Не найдено' }, 404);
    };
  </script>

Дальше ваша часть. Список карточек в разметке остаётся (без скрипта он должен быть виден), а скрипт при открытии страницы запросит свежий список с сервера и заменит им карточки. В имитации к списку добавлен один пункт «Новый пункт с сервера»: так вы увидите, что данные пришли именно оттуда. Форма заявки получает отправку без перезагрузки. И поиграйте переключателем связи: при «обрыве» и «500» прежний список должен остаться на месте, появиться понятное сообщение и кнопка «Повторить».

Если вы скачаете сайт, положите рядом файл items.json (образец выше). Пока его нет, страница честно покажет сохранённые в разметке карточки и сообщение об ошибке.

Одну странность вы заметите сами: в блоке «быстрый переход» из прошлых уроков нового пункта нет. Скрипт построил его один раз при загрузке и ничего не знает о том, что список потом изменился. Исправить можно, но чем больше на странице мест, которые надо обновлять вручную при каждой смене данных, тем больше шансов что-то забыть. К этой проблеме мы вернёмся в уроке про фреймворки.

Что было не так раньше

До fetch данные запрашивали через XMLHttpRequest, громоздкий объект с колбэками, который к тому же умел блокировать страницу на время запроса. Приём называли AJAX (этот термин в 2005 году придумал Джесси Джеймс Гарретт, описывая Gmail и Google Maps): страница обновляется кусками, без перезагрузки. Обмен шёл в XML или в JSON, а чужие сайты запрашивали хитростью под названием JSONP, которая позволяла любому подгружаемому скрипту делать на вашей странице что угодно. Всё это упрощали библиотеки вроде jQuery со своим $.ajax.

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

Практика

Готовлю вашу страницу…

Тест

Вопрос 1 из 7

Запрос на сервер идёт три секунды. Что происходит со страницей, если обращаться к нему через fetch?

Один ответ

Карточки

Эти карточки попадут в повторение, когда вы завершите урок.

  • Почему запросы на сервер асинхронные?

    Ответ

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

  • Что такое промис и какие у него состояния?

    Ответ

    Обещание результата. pending (ждём), fulfilled (результат есть), rejected (сбой). await внутри async-функции ждёт промис, а try/catch ловит отказ.

  • Что обязательно проверять после fetch?

    Ответ

    response.ok. fetch отклоняется только при сбое сети, а ответы 404 и 500 приходят как обычные. Без проверки страница покажет «данные», которых нет.

  • Каковы правила JSON?

    Ответ

    Текст с массивами, объектами, строками, числами, true, false и null. Имена и строки только в двойных кавычках; без комментариев и без запятой после последнего элемента. JSON.parse читает, JSON.stringify пишет.

  • Какие состояния надо показать посетителю при загрузке?

    Ответ

    Загрузка (индикатор), успех (данные), ошибка (понятный текст без жаргона и кнопка «Повторить»). Индикатор убирают в finally, чтобы он не остался навсегда.

Источники

Дальше

Теперь вы знаете: асинхронность: промисы и async/await, fetch и запросы к серверу, JSON, обработка ошибок и отказов.