XMLHttpRequest

Obiekt XMLHttpRequest istnieje w Javascript nierozerwalnie od momentu powstania Ajax i służy do nawiązywania dynamicznych połączeń XHR.

W dzisiejszych czasach mamy dla niego nowszy zamiennik w postaci Fetch i to prawdopodobnie z niego będziesz głównie korzystał. Zasady działania tu i tam są podobne, dlatego warto poznać obydwa, zwłaszcza, że Fetch nie zadziała na każdej przeglądarce.

Nawiązujemy połączenie

Pierwszą czynnością jaką musimy wykonać to skonfigurowanie połączenia za pomocą metody open(typ, url, [async, login*, password*]).
Metoda ta przyjmuje 3 atrybuty: typ połączenia (get, post, put, patch, delete), adres do którego się łączymy, oraz trzeci określający czy nasze połączenie ma być asynchroniczne czy synchroniczne. Dodatkowo możemy tutaj podać login i hasło w przypadku Basic HTTP Authentication.

Po wstępnej konfiguracji wysyłamy nasze połączenie za pomocą metody send().


const xhr = new XMLHttpRequest();

//typ połączenia, url, czy połączenie asynchroniczne
xhr.open("GET", "https://jsonplaceholder.typicode.com/posts", true);

xhr.send();

Metoda send() służy do wysyłania połączenia na serwer. Jeżeli w danym połączeniu wysyłamy dane, musimy je podać jako atrybut tej metody. W przypadku gdy nie wysyłamy żadnych danych nie podajemy nic, a domyślnie zostanie użyta wartość null. Niektórzy programiści wizualnie wstawiają tutaj null dla zabezpieczenia w przypadku starych przeglądarek (1):


//GET
xhr.send(null);

//POST
xhr.send(formData);

Czekamy na odpowiedź

Powyższe połączenie (funkcja open()) za pomocą 3 parametru ustawiliśmy na asynchroniczne. Spowoduje to, że reszta kodu nie będzie blokowana przed wykonywana.

Aby wykryć moment kiedy dane połączenie się zakończyło, musimy podłączyć do obiektu nasłuchiwanie odpowiedniego zdarzenia.

Dla obiektu XMLHttpRequest najważniejsze zdarzenia to:

load Połączenie zakończone powodzeniem
error Błąd nawiązywania połączenia (np. przerwało połączenie)
progress Postęp wczytywania

Mamy też zdarzenia abort (anulowanie połączenia), timeout (przekroczony maksymalny czas połączenia), loadstart/loadend (rozpoczęcie i zakończenie połączenia - nie ważne czy pozytywne) - ale nie są one aż tak często wykorzystywane.

Każde połączenie może zakończyć się sukcesem (zostały pobrane dane) lub się nie udać (np. brak połączenia z internetem). W tym pierwszym przypadku zadziała zdarzenie load, natomiast w tym drugim zdarzenie error.

Zdarzenie load oznacza tylko to, że nasze połączenie zakończyło się pozytywnie i dostaliśmy w odpowiedzi jakieś dane. Zwrócone dane mogą zawierać odpowiedź której oczekiwaliśmy, ale też odpowiedź ze strony jakiegoś błędu (np. status 404, 500, czy 301 w przypadku przekierowania). Przed przystąpieniem do operowania na właściwych danych, dobrą manierą jest sprawdzenie, czy status naszego połączenia wynosi 200 i czy mamy realne dane:


const xhr = new XMLHttpRequest();

xhr.addEventListener("load", e => {
    //tutaj możemy też sprawdzać inne statusy - np. 404, 500
    if (xhr.status === 200) {
        console.log("Wynik połączenia:");
        console.log(xhr.response);
    }
});

xhr.addEventListener("error", e => {
    alert("Niestety nie udało się nawiązać połączenia");
});

xhr.open("GET", "https://jsonplaceholder.typicode.com/posts", true);
xhr.send();

Gdy połączenie zakończy się pozytywnie, możemy skorzystać z dodatkowych właściwości:

responsezawiera właściwą treść odpowiedzi
statuszawiera status połączenia. Status 200 oznacza pozytywne zwrócenie danych, 400 brak strony, 500 błąd serwera itp.
statusTextzawiera status połączenia w formie tekstowej. Dla 200 będzie to OK, dla 404 "Not Found", dla 403 "Forbidden" itp.

Właściwość response zawiera zwrócone dane. Domyślnie są one w formacie tekstowym. Takie dane powinniśmy skonwertować na odpowiedni format:


const xhr = new XMLHttpRequest();

xhr.addEventListener("load",  e => {
    if (xhr.status === 200) {
        const json = JSON.parse(xhr.response);
        console.log(json);
    }
});

xhr.open("GET", "https://jsonplaceholder.typicode.com/posts", true);
xhr.send();

Możemy też przed wykonaniem połączenia ustawić typ danych jakich oczekujemy, dzięki czemu unikniemy ręcznej konwersji. Służy do tego właściwość responseType, która przyjmuje wartości:

"" (domyślnie) zwraca dane w formacie string
"text" zwraca dane w formacie string
"arraybuffer" zwraca dane jako ArrayBuffer
"blob" zwraca dane jako Blob
"document" zwraca dane jako dokument XML
"json" zwraca dane jako JSON

const xhr = new XMLHttpRequest();

xhr.responseType = "json"

xhr.addEventListener("load, e => {
    if (xhr.status === 200) {
        console.log(xhr.response);

        for (const el of xhr.response) {
            console.log(el.title);
        }
    }
});

xhr.open("GET", "https://jsonplaceholder.typicode.com/posts", true);
xhr.send();

Wysyłanie danych

Aby wysłać dane na serwer, musimy je podać jako wartość dla funkcji send().

Wysyłając dane na serwer możemy je wysłać w dowolnym formacie - np. jako zwykły prosty tekst.


const xhr = new XMLHttpRequest();
xhr.addEventListener("load", e => {...}); //po wysyłce czekam na odpowiedź z serwera

xhr.open("POST", "...", true); //wysyłam dane
xhr.send("wysylanyTekst");

W większości sytuacji zamiast pojedynczego tekstu będziemy chcieli wysłać porcję danych, które serwer odczyta jako oddzielne zmienne. Żeby to zrobić, musimy naszą wysyłkę odpowiednio zakodować, ale też poinformować serwer w jakim formacie te dane mu wysyłamy.

Robimy to ustawiając za pomocą funkcji setRequestHeader() nagłówek Content-Type na odpowiedni format.

Jest to sytuacja podobna do komunikacji przeglądarka-serwer. Gdy przeglądarka wysyła na serwer jakieś dane (z formularza na stronie), informuje serwer o typie wysyłanych danych właśnie za pomocą tego nagłówka. Podobnie jest gdy coś ściągamy z internetu - wtedy serwer podobnym nagłówkiem informuje naszą przeglądarkę jakie dane jej wysłał. Takie informowanie dzieje się automatycznie, natomiast w przypadku dynamicznych połączeń za pomocą Javascript musimy taki nagłówek ustawiać ręcznie (chociaż są od tego wyjątki).

Do wysyłania danych najczęściej używane jest jedno z trzech formatów:

  • application/x-www-form-urlencoded - dane są kodowane do postaci przypominającej URL
  • multipart/form-data - dane są kodowane do postaci bloków
  • application/json - najczęściej używane przy komunikacji z wszelakimi API

W HTML5 istnieje też kodowanie text/plain. Typ ten stosowany jest automatycznie, gdy wysyłamy dane tekstowe i nie ustawimy żadnego typu (tak jak w powyższym przykładzie). Dane takie wysyłane są w postaci czytelnej dla człowieka (jako zwykły tekst). Niekoniecznie jednak forma ta jest czytelna dla serwera, dlatego kodowania tego używa się raczej tylko w fazie testowania połączeń.

Opisywane tutaj typy kodowania możesz przetestować na przykładowej stronie.

Wysyłanie prostych danych tekstowych

Pierwsze z wymienionych kodowań - application/x-www-form-urlencoded - możemy stosować w przypadkach gdy chcemy przesłać kilka prostych danych tekstowych.

Dane wysyłane w ten sposób powinny być zakodowane do postaci przypominającej adres URL składający się z par klucz=wartość rozdzielonych znakiem &, a dodatkowo wszelkie znaki nie alfanumeryczne muszą być tutaj odpowiednio zakodowane:


name=Przyk%C5%82adowe+imi%C4%99&surname=Przyk%C5%82adowe+nazwisko

const data = {
    name : "Karol Nowak",
    title : "Przykładowy tytuł",
}

function prepareData(dataToCode) {
    const dataPart = [];
    for (let key in dataToCode) {
        dataPart.push(encodeURIComponent(key) + "=" + encodeURIComponent(data[key]));
    }
    //w adresach URL spacja występuje w postaci %20, natomiast
    //w danych wysyłanych przez formularze spacja oznaczona jest jako +
    //https://stackoverflow.com/questions/1634271/url-encoding-the-space-character-or-20#answer-1634293
    return dataPart.join("&").replace(/%20/g, "+");
}

//konwertuję dane do odpowiedniego formatu
const dataToSend = prepareData(data);

const xhr = new XMLHttpRequest();
xhr.addEventListener("load", e => {
    console.log(xhr.response);
});
xhr.open("POST", "http://jakis-adres-odbioru.pl", true);

//ustawiam odpowiedni typ danych - dla nazwy nagłówka nie ma znaczenia wielkość liter
//więc spokojnie może być "content-type", ale też "Content-type"
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");

xhr.send(dataToSend);

Wysyłanie danych złożonych

Podobnie do klasycznych formularzy jeżeli wysyłane dane zawierają pliki (dane binarne), powinniśmy skorzystać z kodowania multipart/form-data.

Dane takie są przesyłane w formie specjalnie zakodowanych kawałków (body part), gdzie każdy ma informacje o przesyłanej rzeczy (jego content-type, długość itp) dlatego nadają się do przesyłania nie tylko danych tekstowych, ale także danych różnego rodzaju - np. tekstów wraz z plikami.


-----------------------------16932567512311583754471123300
Content-Disposition: form-data; name="name"

Karol Nowak
-----------------------------16932567512311583754471123300
Content-Disposition: form-data; name="title"

Przykładowy tytuł
-----------------------------16932567512311583754471123300--

Żeby nie było niedomówień. Format ten możemy także wykorzystać do wysyłania prostych danych tekstowych (tak jak w przypadku application/x-www-form-urlencoded). W wielu przypadkach jednak proste dane typu klucz-wartość zakodowane w ten sposób będą miały większą długość niż te same dane zakodowane za pomocą application/x-www-form-urlencoded. Jeżeli jednak różnica w wielkości (często bardzo mała) jest dla ciebie do zaakceptowania, śmiało sięgaj po to kodowanie, ponieważ jest zazwyczaj łatwiejsze w użyciu.

Formatu tego nie kodujemy ręcznie, natomiast używamy do tego interfejsu FormData(). Dodatkowym plusem jest tutaj to, że nie musimy ustawiać nagłówka, ponieważ jest on ustawiany automatycznie.


const name = "Karol Nowak";
const title = "Przykładowy tytuł";
const inputFile = document.querySelector("input[type=file]");

const dataToSend = new FormData();
dataToSend.append("name", name);
dataToSend.append("title", title);
dataToSend.append("photo", inputFile.files[0]);

const xhr = new XMLHttpRequest();
xhr.open("POST", "http://jakis-adres-odbioru.pl", true);
xhr.send(dataToSend);

Interfejs FormData udostępnia nam kilka przydatnych metod.

  • formData.append(name, value) - dodaje nową wartość o danej nazwie
  • formData.append(name, blob, fileName) – dodaje wartość o danej nazwie tak jakby było pobrane bezpośrednio z pola <input type="file">, trzeci parametr oznacza nazwę pliku
  • formData.delete(name) – usuwa wartość o danej nazwie
  • formData.get(name) – pobiera wartość pola o danej nazwie
  • FormData.has(name) – zwraca true/false w zależności od tego czy dana wartość istnieje
  • formData.set(name) - nadpisuje wartość o danej nazwie

W codziennej pracy będziesz zapewne głównie używał pokazanej powyżej append().

W przypadku gdy chcesz wysłać dane z całego formularza, nie musisz dodawać wartość każdego pola oddzielnie. Wystarczy przekazać do FormData cały formularz:


const form = document.querySelector("form");
const dataToSend = new FormData(form);

const xhr = new XMLHttpRequest();
xhr.open("POST", "http://jakis-adres-odbioru.pl", true);
xhr.sent(dataToSend);

Wysyłanie danych w formacie JSON

Kolejny bardzo często stosowany format to application/json. Jest on najczęściej stosowany w komunikacji z RestAPI. Całych plików tutaj nie prześlemy, ponieważ wysyłane dane to odpowiednio zakodowany tekst.

Do zakodowania danych skorzystamy z funkcji JSON.stringify():


const ob = {
    name : "Karol Nowak",
    title : "Przykładowy tytuł"
}

//zamieniam na format JSON
const dataToSend = JSON.stringify(ob);

const xhr = new XMLHttpRequest();
xhr.open("POST", "http://jakis-adres-odbioru.pl", true);

//ustawiam odpowiedni typ danych
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(dataToSend);

Postęp pobierania i wysyłania danych

Jedną z cech odróżniającą XMLHttpRequest od jego młodszego brata - Fetch - jest to, że w przypadku XMLHttpRequest możemy reagować na zmianę postępu wysyłania i pobierania danych.

Obie czynności są do siebie bardzo podobne. Różnicą jest to, że w przypadku wysyłania danych odwołujemy się do właściwości upload obiektu XMLHttpRequest, natomiast w przypadku ściągania danych odwołujemy się bezpośrednio do tego obiektu.

Sprawdzanie postępu przy wysyłaniu danych jest raczej proste, ponieważ przeglądarka zna wielkość wysyłanego pliku a i bez problemu może sprawdzić, ile danych zostało wysłanych. Jedyną rzeczą, którą musimy zrobić to podłączyć się pod zdarzenie progress właściwości upload. Dla zabezpieczenia czy rzeczywiście przeglądarka ma dostęp do tych danych stosujemy właściwość lengthComputable:

XMLHttpRequestUpload

...
xhr.upload.addEventListener("progress", e => {
    if (e.lengthComputable) {
        const progress = (e.loaded / e.total) * 100;
        console.log(progress);
    } else {
        //nie da się wyliczyć postępu
    }
});

xhr.open(...);
...

Stosowny przykład znajdziesz na tej stronie.

W przypadku postępu ściągania danych wykonujemy podobne działanie jak powyżej. Tym razem w odwołaniu pomijamy właściwość upload:


...
xhr.addEventListener("progress", e => {
    if (e.lengthComputable) {
        const progress = (e.loaded / e.total) * 100;
        console.log(progress)
    } else {
        //nie da się wyliczyć postępu
    }
});

xhr.open(...);
...

Niestety takie wyliczenie ściąganych danych nie zawsze będzie możliwe, ponieważ dość często przeglądarka nie wie dokładnie ile danych zostanie wysłanych z serwera. Żeby takie wyliczenie było możliwe, serwer powinien zwrócić nam długość odpowiedzi w nagłówku Content-Length:

content length

Stosowny przykład znajdziesz na tej stronie.

Dodatkowo ściągnięte dane nie pojawią się automatycznie na stronie, a i nie będziemy mogli za pomocą javascriptu zapisać ich na dysk użytkownika. Rozwiązaniem może tutaj być ściągnięcie danych pod postacią "blob", a następnie stworzenie na ich podstawie linku, który będzie otwierał dynamicznie stworzony plik.

XMLHttpRequest i reakcja na wczytanie danych

Gdybyśmy chcieli stworzyć funkcję wczytującą dane, powinniśmy w jakiś sposób zareagować na jej zakończenie. Możemy tutaj zastosować jedną z poznanych wcześniej technik.


//za pomocą obietnic
function loadData(url) {
    return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.responseType = "json"
        xhr.addEventListener("load", e => {
            if (xhr.status === 200) {
                resolve(xhr.response);
            }
        });
        xhr.addEventListener("error", e => {
            reject("Błąd wczytywania danych");
        });

        xhr.open("GET", url, true);
        xhr.send();
    });
}

loadData("https://jsonplaceholder.typicode.com/")
    .then(res => {
        console.log(res);
    })

//za pomocą async/await
(async () => {
    async function loadData(url) {
        return new Promise((resolve, reject) => {
            const xhr = new XMLHttpRequest();
            xhr.responseType = "json"
            xhr.addEventListener("load", e => {
                if (xhr.status === 200) {
                    resolve(xhr.response);
                }
            });
            xhr.addEventListener("error", e => {
                reject("Błąd wczytywania danych");
            });

            xhr.open("GET", url, true);
            xhr.send();
        });
    }

    const data = await loadData("https://jsonplaceholder.typicode.com/");
    console.log(data);
})();

Co byś nie napisał, wychodzi sporo kodu. Dla niektórych zbyt sporo. Jak zobaczysz za chwilę fetch niweluje ten problem - a to głównie dlatego, że sam z siebie zwraca obietnicę, na którą możemy od razu zareagować.

Trening czyni mistrza

Jeżeli chcesz sobie potrenować zdobytą wiedzę z tego działu, to zadania znajdują się w repozytorium pod adresem: https://github.com/kartofelek007/zadania-ajax

W repozytorium jest branch "solutions". Tam znajdziesz przykładowe rozwiązania.

Wszelkie prawa zastrzeżone. Jeżeli chcesz używać jakiejś części tego kursu, skontaktuj się z autorem.
Aha - i ta strona korzysta z ciasteczek.

Menu