XHR в JavaScript: как работает XMLHttpRequest и асинхронный запрос

XHR в JavaScript: как работает XMLHttpRequest и асинхронный запрос Полезное

XHR (XMLHttpRequest) — встроенный в браузер объект JavaScript, который отправляет HTTP-запрос на сервер и получает ответ без перезагрузки страницы. По умолчанию запрос асинхронный: метод send() сразу возвращает управление, а ответ приходит позже в обработчик события load. Ниже — рабочий пример целиком, разбор open/send/onload, состояния readyState, обработка ошибок и сравнение с fetch.

Примеры проверены 24.09.2026 в Chromium 152 на локальном стенде (Node.js), код стенда — в первом разделе.

Три термина, которые часто путают

Термин Что это Пример
AJAX Подход: страница обменивается данными с сервером в фоне и обновляет только часть себя Подгрузка комментариев без перезагрузки
XHR Конкретный браузерный API для такого обмена, объект XMLHttpRequest new XMLHttpRequest()
fetch Более новый браузерный API для того же, основан на Promise fetch('/api/users')

AJAX можно сделать и через XHR, и через fetch. Слово XML в названии историческое: XHR передает любой текст, JSON, двоичные данные (Blob, ArrayBuffer) и FormData.

Асинхронность здесь означает одно конкретное свойство: пока идет сетевой запрос, основной поток не ждет, страница продолжает реагировать на клики и прокрутку. Обработчик ответа выполнится позже, когда ответ придет и до него дойдет очередь в цикле событий.

Стенд для примеров

XHR не работает со страницы, открытой как файл (file://): нужен HTTP-сервер. Минимальный сервер на Node.js без зависимостей:

// server.js - запуск: node server.js, затем открыть http://localhost:8765/
const http = require('http');
const users = [{ id: 1, name: 'Анна' }, { id: 2, name: 'Борис' }];

http.createServer((req, res) => {
  const json = (code, data) => {
    res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
    res.end(typeof data === 'string' ? data : JSON.stringify(data));
  };
  if (req.url === '/') {
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    return res.end('<!doctype html><meta charset="utf-8"><ul id="list"></ul>');
  }
  if (req.url === '/api/users' && req.method === 'GET') return json(200, users);
  if (req.url === '/api/users' && req.method === 'POST') {
    let body = '';
    req.on('data', chunk => { body += chunk; });
    req.on('end', () => {
      let data;
      try { data = JSON.parse(body); } catch { return json(400, { error: 'bad json' }); }
      if (!data || typeof data.name !== 'string') return json(400, { error: 'name required' });
      json(201, { ...data, id: 3 });
    });
    return;
  }
  if (req.url === '/api/slow') return setTimeout(() => json(200, { ok: true }), 3000);
  if (req.url === '/api/broken') return json(200, '{oops');
  json(404, { error: 'not found' });
}).listen(8765);

Код ниже вставляется в консоль разработчика (F12) на странице http://localhost:8765/.

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

const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/users');
xhr.responseType = 'json';
xhr.timeout = 5000;

xhr.onload = () => {
  if (xhr.status >= 200 && xhr.status < 300) {
    console.log('Пользователи:', xhr.response.map(u => u.name).join(', '));
  } else {
    console.log('Ошибка HTTP:', xhr.status);
  }
};
xhr.onerror = () => console.log('Сетевая ошибка: ответа нет');
xhr.ontimeout = () => console.log('Сервер не ответил за 5 секунд');

xhr.send();
console.log('send() вернул управление, ответа еще нет');

В консоли две строки, и порядок важен — это и есть асинхронность:

send() вернул управление, ответа еще нет
Пользователи: Анна, Борис

Разбор по шагам:

  1. new XMLHttpRequest() создает объект запроса. Библиотеки не нужны, конструктор есть во всех современных браузерах. В Node.js его нет — там используют fetch.
  2. open(method, url) только настраивает запрос: метод и адрес. Соединение он не открывает.
  3. responseType = 'json' просит браузер самому разобрать тело как JSON, в xhr.response окажется готовый объект. Без этого тело лежит строкой в xhr.responseText, и нужен JSON.parse.
  4. timeout — лимит ожидания в миллисекундах, 0 означает «без лимита».
  5. Обработчики onload, onerror, ontimeout назначаются до send().
  6. send() отправляет запрос. У GET тела нет, поэтому аргумент не передают (или передают null).

Полная сигнатура: open(method, url, async, user, password). Обязательны первые два аргумента. async по умолчанию true; false делает запрос синхронным (об этом ниже). user и password — для HTTP-аутентификации, в учебном коде их не используют.

Ошибка новичка: читать ответ сразу после send()

Неверно:

const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/users');
xhr.send();
console.log('responseText:', JSON.stringify(xhr.responseText));
console.log('readyState:', xhr.readyState);

Фактический результат — пустая строка, ответа еще нет:

responseText: ""
readyState: 1

Исправление: все, что зависит от ответа, переносится в onload (как в минимальном примере) или в Promise-обертку, показанную ниже.

readyState: пять состояний запроса

readyState Константа Что произошло
0 UNSENT Объект создан, open() еще не вызван
1 OPENED Вызван open()
2 HEADERS_RECEIVED Пришли статус и заголовки ответа
3 LOADING Идет загрузка тела
4 DONE Запрос завершен: успешно, с ошибкой, по таймауту или отменен
const xhr = new XMLHttpRequest();
console.log('после new: readyState =', xhr.readyState);
xhr.onreadystatechange = () => {
  console.log('readyState =', xhr.readyState, 'status =', xhr.status);
};
xhr.open('GET', '/api/users');
xhr.send();
после new: readyState = 0
readyState = 1 status = 0
readyState = 2 status = 200
readyState = 3 status = 200
readyState = 4 status = 200

Для большого ответа состояние 3 может прийти несколько раз. В старом коде часто встречается onreadystatechange с проверкой readyState === 4; в новом проще onload, onerror, ontimeout — каждое событие отвечает за свой исход. Важная граница: readyState === 4 не означает успех, это просто «запрос закончился».

Обработка ошибок: что ловит onload, а что onerror

Частая ошибка — считать, что при 404 сработает onerror. Неверно:

const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/userz');
xhr.responseType = 'json';
xhr.onload = () => {
  console.log('onload, status =', xhr.status);
  console.log(xhr.response.map(u => u.name));
};
xhr.onerror = () => console.log('onerror');
xhr.send();

Фактический результат: сработал onload, а затем код упал, потому что вместо массива пришел объект с описанием ошибки (строку браузера Failed to load resource ... 404 из вкладки сети здесь опускаем):

onload, status = 404
Uncaught TypeError: xhr.response.map is not a function

Исправление — проверять xhr.status внутри onload, как в минимальном примере. Сводка исходов (проверено на стенде):

Ситуация Событие xhr.status xhr.response при responseType = 'json'
200, корректный JSON load 200 объект
Ответ 404 (так же ведут себя 500 и другие коды) load 404 тело ответа сервера
200, но тело не JSON (/api/broken) load 200 null
Сервер недоступен или запрет CORS error 0 null
Истек timeout timeout 0 null
Вызван abort() abort 0 null

Правило: HTTP-ошибка — это ответ, он приходит в load; error означает, что ответа не было вообще. Событие loadend срабатывает после любого из исходов, в нем удобно снимать индикатор загрузки.

Отмена и таймаут на медленном адресе:

const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/slow');
xhr.onabort = () => console.log('abort, status =', xhr.status);
xhr.onload = () => console.log('onload не вызовется');
xhr.send();
setTimeout(() => xhr.abort(), 500);
abort, status = 0

abort() нужен, когда ответ стал неактуален: пользователь ушел со вкладки или ввел новый поисковый запрос.

POST: отправка JSON на сервер

const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/users');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.responseType = 'json';
xhr.onload = () => console.log(xhr.status, JSON.stringify(xhr.response));
xhr.send(JSON.stringify({ name: 'Вера', job: 'QA' }));
201 {"name":"Вера","job":"QA","id":3}

setRequestHeader вызывается после open() и до send(). Объект нужно превратить в строку через JSON.stringify: если передать его напрямую, отправится строка [object Object]. Для HTML-формы и файлов удобнее send(new FormData(form)) — заголовок Content-Type с границей частей браузер поставит сам.

Вывод данных на страницу без XSS

Данные с сервера нельзя вставлять через innerHTML: имя пользователя вида <img src=x onerror=...> выполнит чужой код. Безопасный вариант — textContent:

const list = document.getElementById('list');
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/users');
xhr.responseType = 'json';
xhr.onload = () => {
  if (xhr.status !== 200 || !Array.isArray(xhr.response)) return;
  for (const user of xhr.response) {
    const li = document.createElement('li');
    li.textContent = user.name;
    list.append(li);
  }
  console.log(list.children.length, 'элемента в списке');
};
xhr.send();

На странице появятся пункты «Анна» и «Борис», в консоли — 2 элемента в списке. Проверка Array.isArray защищает от ответа неожиданной формы. Это закрывает только вывод: проверку прав и валидацию входа делает сервер.

XHR и Promise: обертка для async/await

function request(method, url, body) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open(method, url);
    xhr.responseType = 'json';
    xhr.timeout = 5000;
    if (body !== undefined) {
      xhr.setRequestHeader('Content-Type', 'application/json');
    }
    xhr.onload = () => {
      if (xhr.status < 200 || xhr.status >= 300) {
        reject(new Error('HTTP ' + xhr.status));
      } else if (xhr.response === null && xhr.status !== 204) {
        reject(new Error('Ответ не является корректным JSON'));
      } else {
        resolve(xhr.response);
      }
    };
    xhr.onerror = () => reject(new Error('Сетевая ошибка или CORS'));
    xhr.ontimeout = () => reject(new Error('Таймаут 5 с'));
    xhr.send(body === undefined ? null : JSON.stringify(body));
  });
}

async function main() {
  const users = await request('GET', '/api/users');
  console.log('GET:', users.length, 'пользователя');
  const created = await request('POST', '/api/users', { name: 'Вера' });
  console.log('POST: id =', created.id);
  for (const url of ['/api/userz', '/api/broken']) {
    try {
      await request('GET', url);
    } catch (e) {
      console.log(url, '->', e.message);
    }
  }
}
main();
GET: 2 пользователя
POST: id = 3
/api/userz -> HTTP 404
/api/broken -> Ответ не является корректным JSON

Граница обертки: при responseType = 'json' браузер отдает null и для битого JSON, и для пустого тела, и для валидного JSON-литерала null, поэтому проверка xhr.response === null отличить их не может. Если сервер законно отвечает null или пустым телом с кодом 200, читайте тело как текст (responseType = 'text') и разбирайте JSON.parse в try.

Синхронный XHR: почему не стоит

Вызов open('GET', url, false) делает запрос синхронным: send() блокирует поток, пока не придет ответ, и страница в это время не реагирует на действия пользователя. В основном потоке страницы такой режим объявлен устаревшим, браузер пишет предупреждение в консоль. Кроме того, для синхронного запроса из документа нельзя задать таймаут — Chromium 152 при попытке выбрасывает:

InvalidAccessError: Failed to set the 'timeout' property on 'XMLHttpRequest': Timeouts cannot be set for synchronous requests made from a document.

Синхронный XHR допустим разве что в Web Worker, где он не блокирует интерфейс; в остальных случаях — асинхронный режим.

XHR или fetch

Критерий XHR fetch
Стиль События (onload, onerror) Promise, удобно с async/await
Ответ 404/500 Приходит в load, проверять status Promise выполняется, проверять res.ok
Таймаут Свойство timeout AbortSignal.timeout(ms)
Отмена xhr.abort() AbortController
Прогресс отправки файла xhr.upload.onprogress Встроенного события нет
Где доступен Браузер, Web Worker Браузер, Worker, Node.js (без флага с 18, стабильный с 21)

Тот же GET на fetch:

async function loadUsers() {
  const res = await fetch('/api/users');
  if (!res.ok) throw new Error('HTTP ' + res.status);
  return res.json();
}
loadUsers().then(users => console.log('fetch:', users.map(u => u.name).join(', ')));
fetch: Анна, Борис

Для нового кода обычно берут fetch. XHR остается в двух случаях: нужен прогресс загрузки файла на сервер (xhr.upload.onprogress) или поддерживается старый проект, где на XHR построены обертки и библиотеки (например, $.ajax в jQuery). Про jQuery-вариант — в статье AJAX-запросы с помощью jQuery.

Если не работает

  • В консоли ошибка про CORS, status = 0. Запрос идет на другой домен, порт или протокол, а сервер не прислал Access-Control-Allow-Origin. Чинится на сервере, не в клиенте. Для отправки cookie на другой домен нужен еще xhr.withCredentials = true и разрешение сервера.
  • xhr.response равен null при статусе 200. Стоит responseType = 'json', а сервер вернул не JSON. Посмотрите тело во вкладке Network.
  • Сервер получил [object Object]. Объект не прошел через JSON.stringify.
  • setRequestHeader выбрасывает InvalidStateError. Метод вызван до open() или после send().
  • Ничего не происходит. Страница открыта как file:// или обработчики назначены после ответа — проверьте адрес и порядок строк.

Выводы

  • XHR (XMLHttpRequest) — браузерный API для HTTP-запросов без перезагрузки страницы; AJAX — подход, XHR — один из способов его реализовать.
  • По умолчанию запрос асинхронный: ответ доступен только в обработчиках событий, а не сразу после send().
  • load срабатывает и на 404/500, поэтому xhr.status проверяется всегда; error означает, что ответа не было.
  • Для нового кода обычно выбирают fetch, XHR нужен для прогресса загрузки файлов и в legacy-проектах.

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

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

С XHR сталкиваются при поддержке существующих сайтов и админок, где запросы написаны до появления fetch, и при загрузке файлов с индикатором прогресса. Понимание событий, статусов и асинхронности нужно и для fetch, и для любых HTTP-клиентов. Работу с сетью, асинхронный JavaScript и DOM последовательно разбирают на курсе «JavaScript-разработчик. Базовый уровень». Попробовать формат можно на открытых уроках Otus.

FAQ

Можно ли отправить несколько запросов одним объектом XHR?
Повторный вызов open() сбрасывает текущий запрос. Для параллельных запросов создают отдельный объект на каждый.

Как получить заголовки ответа?
xhr.getResponseHeader('Content-Type') возвращает один заголовок, xhr.getAllResponseHeaders() — все одной строкой. Для запросов на другой домен видны только заголовки, разрешенные сервером через CORS.

Работает ли XMLHttpRequest в Node.js?
Встроенного XMLHttpRequest в Node.js нет. На сервере используют встроенный fetch или модуль http.

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