6 лютого 2022 р.

XMLHttpRequest

XMLHttpRequest – це вбудований об’єкт браузера, який дозволяє виконувати HTTP-запити за допомогою JavaScript.

Попри те, що в назві є слово “XML”, він може працювати з будь-якими даними, а не лише у форматі XML. Ми можемо завантажувати з/на сервер файли, відстежувати прогрес та багато іншого.

Нині існує інший, більш сучасний метод fetch, який, у більшості випадків, замінив XMLHttpRequest.

У сучасній веб-розробці XMLHttpRequest використовується з трьох причин:

  1. З історичних причин: нам потрібно підтримувати наявні скрипти які використовують XMLHttpRequest.
  2. Нам потрібно підтримувати старі браузери і ми не хочемо використовувати поліфіли (наприклад, щоб зменшити кількість коду).
  3. Нам потрібен функціонал, який fetch ще не підтримує, наприклад відстежування ходу завантаження.

Щось з цього звучить знайомо? Якщо так, то продовжуйте знайомство з XMLHttpRequest. В іншому випадку можливо є сенс одразу перейти до Fetch.

Основи

XMLHttpRequest має два режими роботи: синхронний і асинхронний.

Давайте спочатку розглянемо асинхронний, оскільки він використовується в більшості випадків.

Щоб зробити запит, нам потрібно виконати 3 кроки:

  1. Створити XMLHttpRequest:

    let xhr = new XMLHttpRequest();

    Конструктор не має аргументів.

  2. Ініціалізувати його, зазвичай це роблять відразу після new XMLHttpRequest:

    xhr.open(method, URL, [async, user, password])

    Цей метод приймає основні параметри запиту:

    • method – HTTP-метод. Зазвичай "GET" або "POST".
    • URL – URL для запиту, зазвичай це рядок, але може бути і об’єктом URL.
    • async – якщо явно встановлено значення false, тоді запит буде синхронним, ми розглянемо це трохи пізніше.
    • user, password – логін та пароль для базової HTTP аутентифікації (якщо потрібно).

    Зверніть увагу, що виклик метода open, всупереч його назві, не відкриває з’єднання. Він лише налаштовує запит, але мережева активність починається лише після виклику метода send.

  3. Надіслати запит.

    xhr.send([body])

    Цей метод відкриває з’єднання та надсилає запит на сервер. Необов’язковий параметр body містить тіло запиту.

    Деякі HTTP-методи запиту, такі як GET, не мають тіла. А деякі з них, наприклад POST, використовують body для завантаження даних на сервер. Ми побачимо приклади цього пізніше.

  4. Прослуховувати події xhr.

    Найчастіше використовуються ці три події:

    • load – спрацьовує коли запит завершено (навіть якщо статус HTTP дорівнює 400 або 500) і відповідь сервера повністю завантажено.
    • error – спрацьовує коли запит не може бути виконано, наприклад мережа не працює або задана не коректна URL-адреса.
    • progress – періодично спрацьовує під час завантаження відповіді, та повідомляє, скільки байтів було завантажено.
    xhr.onload = function() {
      alert(`Завантажено: ${xhr.status} ${xhr.response}`);
    };
    
    xhr.onerror = function() { // спрацьовує лише в тому випадку, якщо запит взагалі неможливо зробити
      alert(`Мережева помилка`);
    };
    
    xhr.onprogress = function(event) { // спрацьовує періодично
      // event.loaded - скільки байтів було завантажено
      // event.lengthComputable = true, якщо сервер надіслав заголовок Content-Length
      // event.total - загальна кількість байтів (якщо сервер надіслав заголовок Content-Length)
      alert(`Отримано ${event.loaded} з ${event.total}`);
    };

Ось повний приклад. Наведений нижче код завантажує /article/xmlhttprequest/example/load із сервера та показує хід завантаження:

// 1. Створюємо новий об’єкт XMLHttpRequest
let xhr = new XMLHttpRequest();

// 2. Налаштовуємо його: GET-запит для URL-адреси /article/.../load
xhr.open('GET', '/article/xmlhttprequest/example/load');

// 3. Відправляємо запит мережею
xhr.send();

// 4. Код нижче буде виконано після отримання відповіді
xhr.onload = function() {
  if (xhr.status != 200) { // аналізуємо HTTP-статус відповіді
    alert(`Помилка ${xhr.status}: ${xhr.statusText}`); // наприклад 404: Не знайдено
  } else { // виводимо результат
    alert(`Запит завершено, отримано ${xhr.response.length} байт`); // властивість `xhr.response` містить відповідь сервера
  }
};

xhr.onprogress = function(event) {
  if (event.lengthComputable) {
    alert(`Отримано ${event.loaded} із ${event.total} байт`);
  } else {
    alert(`Отримано ${event.loaded} байт`); // якщо сервер не надіслав заголовок Content-Length
  }

};

xhr.onerror = function() {
  alert("Не вдалося виконати запит");
};

Після відповіді сервера, ми можемо отримати результат у таких властивостях xhr:

status
Код HTTP статусу (число): 200, 404, 403 і так далі, може бути 0 у разі помилки не пов’язаної з HTTP.
statusText
Статусне повідомлення HTTP (рядок): зазвичай OK для 200, Not Found для 404, Forbidden для 403 тощо.
response (старі скрипти можуть використовувати responseText)
Тіло відповіді сервера.

Ми також можемо вказати час очікування відповіді за допомогою відповідної властивості:

xhr.timeout = 10000; // час очікування в мс, 10 секунд

Якщо запит не завершився протягом заданого часу, він скасовується і спрацьовує подія timeout.

URL-адреса з параметрами

Щоб додати параметри до URL-адреси, наприклад ?name=value, і забезпечити належне кодування, ми можемо використовувати об’єкт URL:

let url = new URL('https://google.com/search');
url.searchParams.set('q', 'test me!');

// параметр 'q' закодований
xhr.open('GET', url); // https://google.com/search?q=test+me%21

Тип відповіді

Ми можемо встановити очікуваний формат відповіді сервера за допомогою властивості xhr.responseType:

  • "" (за замовчуванням) – отримати як рядок,
  • "text" – отримати як рядок,
  • "arraybuffer" – отримати як ArrayBuffer (для двійкових даних див. розділ ArrayBuffer, бінарні масиви),
  • "blob" – отримати як Blob (для двійкових даних див. розділ Blob),
  • "document" – отримати як XML-документ (може використовувати XPath та інші методи XML) або HTML-документ (на основі типу MIME для отриманих даних),
  • "json" – отримати як JSON (автоматичний аналіз).

Наприклад, отримаймо відповідь у форматі JSON:

let xhr = new XMLHttpRequest();

xhr.open('GET', '/article/xmlhttprequest/example/json');

xhr.responseType = 'json';

xhr.send();

// тіло відповіді {"message": "Привіт, світ!"}
xhr.onload = function() {
  let responseObj = xhr.response;
  alert(responseObj.message); // Привіт, світ!
};
Будь ласка, зверніть увагу:

У старих скриптах ви також можете знайти властивості xhr.responseText і навіть xhr.responseXML.

Вони існують з історичних причин, щоб отримати рядок або XML-документ. Зараз ми повинні встановлювати формат у xhr.responseType і отримувати відповідь через властивість xhr.response, як показано вище.

Стани запиту

XMLHttpRequest змінює свій стан в процесі виконання запиту. Поточний стан доступний у властивості xhr.readyState.

Ось список усіх станів, згідно зі специфікацією:

UNSENT = 0; // початковий стан
OPENED = 1; // викликано метод open
HEADERS_RECEIVED = 2; // отримано заголовки відповіді
LOADING = 3; // завантажується відповідь (отримано пакет даних)
DONE = 4; // запит завершено

Стани об’єкту XMLHttpRequest змінюються у порядку 0123 → … → 34. Стан 3 повторюється щоразу, коли отримується пакет даних.

Ми можемо відстежувати їх за допомогою події readystatechange:

xhr.onreadystatechange = function() {
  if (xhr.readyState == 3) {
    // завантаження
  }
  if (xhr.readyState == 4) {
    // запит завершено
  }
};

Ви можете натрапити на обробники події readystatechange у дуже старому коді. Вони там з історичних причин, оскільки раніше не було події load та інших. Нині замість цього використовують обробники подій load/error/progress.

Скасування запиту

Ми можемо скасувати запит у будь-який час. Це можна зробити за допомогою метода xhr.abort():

xhr.abort(); // скасовуємо запит

Це ініціює подію abort, а xhr.status стає 0.

Синхронні запити

Якщо в методі open для третього параметра async встановлено значення false, то запит виконується синхронно.

Іншими словами, виконання JavaScript призупиняється в момент виклику метода send() і відновлюється після отримання відповіді. Схожим чином поводяться команди alert та prompt.

Ось переписаний приклад, де 3-й параметр open встановлено у false:

let xhr = new XMLHttpRequest();

xhr.open('GET', '/article/xmlhttprequest/hello.txt', false);

try {
  xhr.send();
  if (xhr.status != 200) {
    alert(`Error ${xhr.status}: ${xhr.statusText}`);
  } else {
    alert(xhr.response);
  }
} catch(err) { // для оброки помилок використовуємо try...catch замість події onerror
  alert("Не вдалося виконати запит");
}

Можливо це і виглядає прийнятно, але синхронні запити використовуються рідко, оскільки вони блокують виконання JavaScript на сторінці до моменту завершення завантаження. У деяких браузерах прокручування стає неможливим. Якщо синхронний запит займає занадто багато часу, браузер може запропонувати закрити «висячу» веб-сторінку.

Багато розширених можливостей XMLHttpRequest, таких як запит до іншого домену або встановлення часу очікування відповіді, недоступні для синхронних запитів. Крім того, як бачите, немає індикаторів прогресу.

Через все це синхронні запити використовуються дуже рідко, майже ніколи. Тож ми більше не будемо їх розглядати.

HTTP-заголовки

XMLHttpRequest дозволяє як надсилати власні заголовки, так і читати їх з відповіді.

Існує 3 методи для роботи з HTTP-заголовками:

setRequestHeader(name, value)

Встановлює заголовок запиту із заданими ім’ям name та значенням value.

Наприклад:

xhr.setRequestHeader('Content-Type', 'application/json');
Обмеження заголовків

Декількома заголовками керує виключно браузер, наприклад Referer і Host. Повний їх список є у специфікації.

XMLHttpRequest не має права їх змінювати з метою безпеки користувача та коректності запиту.

Видалити заголовок неможливо

Ще одна особливість XMLHttpRequest полягає в тому, що неможливо скасувати setRequestHeader.

Після того, як заголовок встановлений, його не можна видалити. Додаткові виклики метода додають інформацію в заголовок, а не перезаписують її.

Наприклад:

xhr.setRequestHeader('X-Auth', '123');
xhr.setRequestHeader('X-Auth', '456');

// заголовок буде таким:
// X-Auth: 123, 456
getResponseHeader(name)

Отримує заголовок відповіді за заданим ім’ям name (крім Set-Cookie і Set-Cookie2).

Наприклад:

xhr.getResponseHeader('Content-Type')
getAllResponseHeaders()

Повертає всі заголовки відповіді, крім Set-Cookie і Set-Cookie2.

Заголовки повертаються у вигляді єдиного рядка, наприклад:

Cache-Control: max-age=31536000
Content-Length: 4260
Content-Type: image/png
Date: Sat, 08 Sep 2012 16:53:16 GMT

Між заголовками завжди є розрив рядка "\r\n" (не залежить від ОС), тому ми можемо легко розділити його на окремі заголовки. Роздільником між ім’ям і значенням заголовка завжди є двокрапка з пробілом ": ". Це зафіксовано в специфікації.

Отже, якщо ми хочемо отримати об’єкт з парами ім’я/значення, нам потрібно додати трохи JS.

Ось так (припускаючи, що якщо два заголовки мають однакову назву, то останній замінює попередній):

let headers = xhr
  .getAllResponseHeaders()
  .split('\r\n')
  .reduce((result, current) => {
    let [name, value] = current.split(': ');
    result[name] = value;
    return result;
  }, {});

// headers['Content-Type'] = 'image/png'

POST, FormData

Щоб зробити запит POST, ми можемо використати вбудований об’єкт FormData.

Синтаксис:

let formData = new FormData([form]); // створюємо об’єкт, він може бути заповнений з елемента <form>
formData.append(name, value); // додаємо поле

Ми створюємо об’єкт, за бажанням додаємо дані з форми, та якщо потрібно, додаємо поля за допомогою метода append, а потім:

  1. xhr.open('POST', ...) – встановлюємо метод POST.
  2. xhr.send(formData) – та надсилаємо форму на сервер.

Наприклад:

<form name="person">
  <input name="name" value="John">
  <input name="surname" value="Smith">
</form>

<script>
  // заповнюємо FormData даними із форми
  let formData = new FormData(document.forms.person);

  // додаємо ще одне поле
  formData.append("middle", "Lee");

  // відправляємо запит
  let xhr = new XMLHttpRequest();
  xhr.open("POST", "/article/xmlhttprequest/post/user");
  xhr.send(formData);

  xhr.onload = () => alert(xhr.response);
</script>

Форма надсилається з кодуванням multipart/form-data.

Або, якщо нам більше подобається формат JSON, тоді використовуємо JSON.stringify і надсилаємо дані як рядок.

Тільки не забудьте встановити заголовок Content-Type: application/json, багато серверних фреймворків автоматично декодують JSON за його наявності:

let xhr = new XMLHttpRequest();

let json = JSON.stringify({
  name: "John",
  surname: "Smith"
});

xhr.open("POST", '/submit')
xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8');

xhr.send(json);

Метод .send(body) досить всеїдний. Він може надсилати майже будь-які дані у body, включаючи об’єкти Blob та BufferSource.

Хід завантаження

Подія progress спрацьовує лише на етапі завантаження даних з сервера.

Тобто якщо ми відправляємо POST запит, то XMLHttpRequest спочатку завантажує наші дані (тіло запиту) на сервер, а потім завантажує відповідь з сервера.

Якщо ми завантажуємо на сервер щось велике, то ми, звичайно, більше зацікавлені у відстеженні прогресу завантаження на сервер. Але xhr.onprogress тут нам не допоможе.

Існує ще один об’єкт без методів, призначений виключно для відстеження подій завантаження на сервер: xhr.upload.

Він генерує події, подібні до xhr, але xhr.upload ініціює їх виключно під час завантаження на сервер:

  • loadstart – розпочато завантаження на сервер.
  • progress – спрацьовує періодично під час завантаження.
  • abort – завантаження перервано.
  • error – помилка не пов’язана з HTTP.
  • load – завантаження успішно завершено.
  • timeout – час очікування завантаження минув (якщо встановлено властивість timeout).
  • loadend – завантаження завершено (успішно або з помилкою).

Приклад обробників:

xhr.upload.onprogress = function(event) {
  alert(`Завантажено на сервер ${event.loaded} із ${event.total} байтів`);
};

xhr.upload.onload = function() {
  alert(`Завантаження на сервер успішно завершено.`);
};

xhr.upload.onerror = function() {
  alert(`Сталася помилка під час завантаження: ${xhr.status}`);
};

Ось приклад із реального життя: завантаження файлу на сервер з індикацією прогресу:

<input type="file" onchange="upload(this.files[0])">

<script>
function upload(file) {
  let xhr = new XMLHttpRequest();

  // відстежуємо хід завантаження на сервер
  xhr.upload.onprogress = function(event) {
    console.log(`Завантажено ${event.loaded} із ${event.total}`);
  };

  // відстежуємо завершення: успішне чи ні
  xhr.onloadend = function() {
    if (xhr.status == 200) {
      console.log("успішно");
    } else {
      console.log("помилка " + this.status);
    }
  };

  xhr.open("POST", "/article/xmlhttprequest/post/upload");
  xhr.send(file);
}
</script>

Запити на інші джерела

XMLHttpRequest може робити запити інші джерела (сайти) використовуючи ту саму політику CORS, що й fetch.

Так само як і fetch, він за замовчуванням не надсилає іншим джерелам заголовки HTTP-авторизації та cookie. Щоб увімкнути їх, встановіть для xhr.withCredentials значення true:

let xhr = new XMLHttpRequest();
xhr.withCredentials = true;

xhr.open('POST', 'http://anywhere.com/request');
...

Дивіться розділ Fetch: Запити між різними джерелами, щоб дізнатися більше про заголовки які використовуються для запитів на інші джерела.

Підсумки

Типовий код GET-запиту з використанням XMLHttpRequest:

let xhr = new XMLHttpRequest();

xhr.open('GET', '/my/url');

xhr.send();

xhr.onload = function() {
  if (xhr.status != 200) { // HTTP помилка?
    // оброблюємо помилку
    alert( 'Помилка: ' + xhr.status);
    return;
  }

  // отримуємо відповідь з властивості xhr.response
};

xhr.onprogress = function(event) {
  // відстежуємо прогрес
  alert(`Завантажено ${event.loaded} із ${event.total}`);
};

xhr.onerror = function() {
  // обробляємо помилку не пов’язану з HTTP (наприклад, якщо мережа не працює)
};

Насправді подій більше, сучасна специфікація перелічує їх (у порядку життєвого циклу):

  • loadstart – запит почався.
  • progress – надійшов пакет даних відповіді, все тіло відповіді на даний момент знаходиться у властивості response.
  • abort – запит було скасовано викликом xhr.abort().
  • error – сталася помилка підключення, наприклад неправильне доменне ім’я. Не спрацьовує для HTTP-помилок, таких як 404.
  • load – запит успішно завершено.
  • timeout – запит було скасовано через тайм-аут (тільки якщо він був встановлений).
  • loadend – спрацьовує після подій load, error, timeout або abort.

Події error, abort, timeout, та load є взаємовиключними. Може спрацювати лише одна з них.

Найчастіше використовувані події – це завершення завантаження (load), помилка завантаження (error), або ми можемо використовувати один обробник loadend і перевірити властивості об’єкта запиту xhr, щоб побачити, що сталося.

Також ми розглянули іншу подію: readystatechange. Вона з’явилася дуже давно, ще до того, як було завершено специфікацію. Нині нема потреби її використовувати, адже є новіші події, але її часто можна зустріти в старих скриптах.

Якщо ж нам потрібно відстежувати прогрес завантаження на сервер, тоді можна прослуховувати ті самі події на об’єкті xhr.upload.

Навчальна карта

Коментарі

прочитайте це, перш ніж коментувати…
  • Якщо у вас є пропозиції, щодо покращення підручника, будь ласка, створіть обговорення на GitHub або одразу створіть запит на злиття зі змінами.
  • Якщо ви не можете зрозуміти щось у статті, спробуйте покращити її, будь ласка.
  • Щоб вставити код, використовуйте тег <code>, для кількох рядків – обгорніть їх тегом <pre>, для понад 10 рядків – використовуйте пісочницю (plnkr, jsbin, codepen…)