Асинхронный запрос в JavaScript: JSON и AJAX через fetch

Асинхронный запрос в JavaScript: JSON и AJAX через fetch Полезное

Асинхронный запрос — это HTTP-запрос, который скрипт отправляет, не останавливая страницу: код идет дальше, а ответ сервера обрабатывается, когда придет. На этом построен AJAX — подход, при котором страница обновляет часть данных без полной перезагрузки. Данные при этом чаще всего передают в формате JSON, а сам запрос в современном JavaScript делают через fetch и async/await.

Ниже — рабочий пример GET и POST с JSON, проверка статуса ответа, таймаут и ошибки, на которых спотыкаются чаще всего. Примеры проверены в Node.js 24 (24.21.0); функция-клиент без изменений работает и в браузере.

Четыре термина, которые путают

Термин Что это Пример
Асинхронный запрос Способ выполнения: запрос не блокирует остальной код Страница отвечает на клики, пока ждет сервер
AJAX Подход: обновлять часть страницы данными с сервера без перезагрузки Подгрузка комментариев, поиск с подсказками
JSON Текстовый формат данных {"id": 1, "title": "JavaScript"}
fetch, XMLHttpRequest Программные интерфейсы браузера для HTTP-запросов fetch('/api/courses')

Буква X в AJAX исторически означает XML, но сейчас ответ почти всегда приходит в JSON. AJAX — не библиотека и не отдельная технология, а название приема.

Синхронно и асинхронно: порядок выполнения

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

console.log('1. Отправляем запрос');
setTimeout(() => console.log('3. Пришел ответ'), 0);
console.log('2. Страница продолжает работать');
1. Отправляем запрос
2. Страница продолжает работать
3. Пришел ответ

Даже с задержкой 0 мс обработчик выполняется после текущего кода. С сетевым запросом так же: результат нельзя получить «в следующей строке» без await или .then().

Минимальный рабочий пример: GET и POST с JSON

Файл demo.mjs поднимает учебный сервер на Node.js и делает к нему запросы. Сохраните его и запустите node demo.mjs (проверено на Node.js 24 LTS; глобальный fetch появился в Node.js 18 как экспериментальный и стал стабильным в 21, а ветки 18 и 20 уже без поддержки; порт 3000 должен быть свободен). Процесс завершится примерно через 3 секунды: столько ждет таймер медленного ответа.

import http from 'node:http';

// Учебный сервер: GET /api/courses, POST /api/orders, GET /api/slow
const server = http.createServer(async (req, res) => {
  const send = (status, data) => {
    res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
    res.end(JSON.stringify(data));
  };
  if (req.method === 'GET' && req.url === '/api/courses') {
    return send(200, [{ id: 1, title: 'JavaScript' }, { id: 2, title: 'Python' }]);
  }
  if (req.method === 'POST' && req.url === '/api/orders') {
    if (!req.headers['content-type']?.startsWith('application/json')) {
      return send(415, { error: 'Нужен Content-Type: application/json' });
    }
    let raw = '';
    for await (const chunk of req) raw += chunk;
    let order;
    try { order = JSON.parse(raw); } catch { return send(400, { error: 'Невалидный JSON' }); }
    if (!Number.isInteger(order?.courseId)) {
      return send(400, { error: 'courseId должен быть целым числом' });
    }
    return send(201, { orderId: 42, courseId: order.courseId });
  }
  if (req.url === '/api/slow') {
    return setTimeout(() => send(200, { ok: true }), 3000);
  }
  send(404, { error: 'Не найдено' });
});

// Клиент: одна функция на все запросы (так же работает в браузере)
async function requestJSON(url, { method = 'GET', body, timeoutMs = 5000 } = {}) {
  const headers = { Accept: 'application/json' };
  if (body !== undefined) headers['Content-Type'] = 'application/json';

  const response = await fetch(url, {
    method,
    headers,
    body: body === undefined ? undefined : JSON.stringify(body),
    signal: AbortSignal.timeout(timeoutMs),
  });

  const isJSON = response.headers.get('content-type')?.includes('application/json');

  if (!response.ok) {
    // тело ошибки может быть пустым или не JSON - статус важнее
    const text = await response.text();
    let detail = text;
    if (isJSON) {
      try { detail = JSON.parse(text)?.error ?? text; } catch { /* оставляем текст */ }
    }
    const error = new Error(`HTTP ${response.status}: ${detail}`);
    error.status = response.status;
    throw error;
  }
  return isJSON ? response.json() : response.text();
}

server.listen(3000, async () => {
  const base = 'http://localhost:3000';
  try {
    console.log('GET:', await requestJSON(`${base}/api/courses`));
    console.log('POST:', await requestJSON(`${base}/api/orders`, { method: 'POST', body: { courseId: 1 } }));
    await requestJSON(`${base}/api/orders`, { method: 'POST', body: { courseId: 'один' } });
  } catch (error) {
    console.log('Ошибка:', error.status, error.message);
  }
  try {
    await requestJSON(`${base}/api/slow`, { timeoutMs: 1000 });
  } catch (error) {
    console.log('Ошибка:', error.name, error.message);
  }
  server.close();
  server.closeAllConnections();
});

В консоли появятся четыре строки: список курсов, созданный заказ, ошибка 400 от сервера и таймаут медленного запроса.

GET: [ { id: 1, title: 'JavaScript' }, { id: 2, title: 'Python' } ]
POST: { orderId: 42, courseId: 1 }
Ошибка: 400 HTTP 400: courseId должен быть целым числом
Ошибка: TimeoutError The operation was aborted due to timeout

Разбор функции requestJSON

  1. fetch(url, options) сразу возвращает промис. await приостанавливает только эту async-функцию, а не всю страницу.
  2. Промис от fetch выполняется, когда пришли статус и заголовки. Тело читается отдельно: response.json() тоже возвращает промис, поэтому нужен второй await.
  3. Для POST объект превращают в строку JSON.stringify(body) и указывают Content-Type: application/json — так сервер понимает, как разбирать тело.
  4. response.ok равно true для статусов 200-299. Все остальное функция превращает в исключение со статусом, чтобы вызывающий код обработал его в одном catch. Статус проверяется до разбора JSON: если прокси вернет 502 с пустым телом, вызывающий код получит HTTP 502, а не маскирующую его ошибку SyntaxError: Unexpected end of JSON input.
  5. AbortSignal.timeout(5000) отменяет запрос, если ответа нет за 5 секунд. Без этого запрос к зависшему серверу может ждать очень долго.

Тип ответа проверяется по заголовку Content-Type: при ошибках сервер или прокси часто отдают HTML, а не JSON.

Ошибка 1: fetch не считает 404 и 500 ошибкой

Неверно — надеяться, что catch сработает на любом неудачном ответе:

const response = await fetch('http://localhost:3000/api/cours');
const data = await response.json();

fetch выполнился успешно, хотя адрес с опечаткой. Дальше два исхода, и оба плохие. Учебный сервер из примера отвечает на 404 JSON-ом, поэтому data молча станет { error: 'Не найдено' }, и код продолжит работать с ошибкой как с данными. Если же сервер или прокси отдает на 404 HTML-страницу, падает уже разбор тела:

SyntaxError: Unexpected token '<', "<!doctype "... is not valid JSON

Исправление — проверять response.ok (или response.status) до чтения данных, как в requestJSON. Промис fetch отклоняется только при сбое сети, отмене или ошибке CORS. Например, при недоступном сервере Node.js 24 выдает TypeError: fetch failed; в браузерах текст этой ошибки отличается.

Ошибка 2: объект отправлен без JSON.stringify

Неверно — передать объект в body напрямую:

const order = { courseId: 1 };
const request = new Request('http://localhost:3000/api/orders', { method: 'POST', body: order });
console.log(request.headers.get('content-type'));
console.log(await request.text());
text/plain;charset=UTF-8
[object Object]

Объект привелся к строке [object Object], а заголовок стал text/plain. Сервер из примера ответит 415. Исправление — body: JSON.stringify(order) и заголовок 'Content-Type': 'application/json'.

Несколько запросов: последовательно или параллельно

Если запросы не зависят друг от друга, не ждите их по очереди: Promise.all запускает все сразу и падает на первой ошибке, Promise.allSettled дожидается всех и сообщает статус каждого.

const results = await Promise.allSettled([
  Promise.resolve({ id: 1 }),
  Promise.reject(new Error('HTTP 503')),
]);
for (const r of results) console.log(r.status, r.value ?? r.reason.message);
fulfilled { id: 1 }
rejected HTTP 503

В реальном коде вместо Promise.resolve и Promise.reject стоят вызовы requestJSON(...). Последовательный await нужен, когда второй запрос использует результат первого.

Как это выглядит на странице

В браузере та же функция requestJSON вызывается из обработчика формы. Страница не перезагружается, результат выводится в блок:

<form id="order">
  <input name="courseId" value="1">
  <button>Записаться</button>
</form>
<p id="result"></p>
<script type="module">
  // сюда вставьте функцию requestJSON из примера выше
  const form = document.querySelector('#order');
  const result = document.querySelector('#result');
  form.addEventListener('submit', async (event) => {
    event.preventDefault();
    try {
      const order = await requestJSON('/api/orders', {
        method: 'POST',
        body: { courseId: Number(form.courseId.value) },
      });
      result.textContent = `Заявка ${order.orderId} создана`;
    } catch (error) {
      result.textContent = `Не получилось: ${error.message}`;
    }
  });
</script>

event.preventDefault() отменяет обычную отправку формы. Данные с сервера выводятся через textContent, а не innerHTML, чтобы строка из ответа не выполнилась как разметка.

fetch, XMLHttpRequest и jQuery.ajax

Критерий fetch XMLHttpRequest jQuery.ajax
Стиль кода Промисы, async/await Обработчики событий Колбэки и промисоподобный jqXHR
Ошибка на 404/500 Нет, проверять response.ok Нет, проверять status Да, вызывается .fail
Прогресс загрузки файла Нет простого способа Есть upload.onprogress Через XHR
Нужна библиотека Нет Нет Да

Для нового кода обычно берут fetch. XMLHttpRequest остается в старых проектах и там, где нужен прогресс отправки файла. Как устроен jQuery-вариант и как переписать его на fetch — в статье jQuery AJAX: $.ajax, $.get, $.post.

JSON: что учесть при отправке

JSON.stringify не переносит все типы JavaScript: Date превращается в строку, undefined и функции в объекте пропадают, BigInt вызывает исключение. На сервере данные снова разбираются из текста, поэтому даты передают строкой ISO и явно преобразуют обратно. Подробнее о правилах формата и разборе — в статье JSON: создание и чтение.

Безопасность асинхронных запросов

  • CORS — ограничение браузера на чтение ответов с другого домена, а не защита сервера: curl или скрипт вне браузера отправят запрос без него. Доступ проверяется на сервере.
  • Данные от клиента сервер проверяет всегда, даже если форма проверяет их в браузере (как Number.isInteger в примере).
  • Если API работает с cookie сессии, запросы, меняющие данные, нужно защищать от CSRF: токен, атрибут SameSite у cookie, проверка Origin.
  • Ответ сервера вставляется на страницу через textContent или экранирование, а не в innerHTML как есть.

Если не получилось

  • SyntaxError: Unexpected token '<' — пришел HTML вместо JSON: неверный URL, 404 или страница ошибки. Посмотрите статус и тело во вкладке Network.
  • В консоли браузера ошибка про CORS — сервер другого домена не разрешил чтение ответа. Нужен заголовок Access-Control-Allow-Origin на сервере или запрос через свой бэкенд.
  • Вместо данных выводится Promise { <pending> } — пропущен await перед fetch или response.json().
  • Сервер отвечает 400 или 415 — тело не в JSON или нет заголовка Content-Type: application/json.
  • TimeoutError — сервер не успел ответить: увеличьте таймаут или разберитесь, почему он медленный.

Выводы

  • Асинхронный запрос не блокирует страницу; AJAX — прием обновления части страницы, JSON — формат данных, fetch — API для запроса.
  • fetch отклоняет промис только при сбое сети, отмене или CORS; статусы 4xx и 5xx проверяются через response.ok.
  • Для POST с JSON нужны JSON.stringify и заголовок Content-Type: application/json.
  • Таймаут задается через AbortSignal.timeout, независимые запросы запускаются параллельно через Promise.all или Promise.allSettled.
  • Данные с сервера выводятся через textContent, проверка входных данных и прав остается на сервере.

Где применяется / связь с практикой

Освойте тему на практике

Асинхронные запросы есть почти в любом современном интерфейсе: формы без перезагрузки, фильтры каталога, бесконечная лента, чат, автосохранение. Работа с fetch, промисами и async/await входит в базу курса «JavaScript-разработчик. Базовый уровень». Разобрать отдельные темы с преподавателем можно на открытых уроках Otus.

FAQ

Можно ли сделать синхронный запрос в браузере?
XMLHttpRequest поддерживает синхронный режим (третий аргумент open равен false), но в основном потоке он устарел и замораживает страницу; у fetch синхронного режима нет.

Чем await отличается от .then()?
Это две записи одного механизма промисов: await читается как последовательный код и работает внутри async-функции или на верхнем уровне модуля, .then() — цепочка колбэков.

Как отменить запрос по кнопке, а не по таймауту?
Создать AbortController, передать controller.signal в fetch и вызвать controller.abort(); промис отклонится с ошибкой AbortError.

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)