XMLHttpRequest

W tym dziale zajmiemy się obsługą XMLHttpRequest - jednego z możliwych podejść do obsługi asynchronicznego pobierania i wysyłania danych.

Kilka słów o XMLHttpRequest

Aby nawiązać połączenie, musimy stworzyć nowy obiekt typu XMLHttpRequest,


let xhr = new XMLHttpRequest();
console.log(xhr);
xmlhttprequest w debugerze

Jak widzisz obiekt ten udostępnia nam kilka właściwości i metod.

Nazwa właściwości Opis
status status połączenia (np 200, 404, 301, 500)
statusText status w formie tekstowej
timeout Jak długo połączenie ma czekać na odpowiedź
responseURL adres odpowiedzi
responseType Typ odpowiedzi
responseText treść odpowiedzi
readyState stan połączenia

Gdy robimy połączenie za pomocą obiektu XMLHttpRequest, przechodzi ono przez pewne fazy, które można odczytać z właściwości readyState:

Wartość readyState Opis
0 połączenie nie nawiązane
1 połączenie nawiązane
2 żądanie odebrane
3 przetwarzanie
4 dane zwrócone i gotowe do użycia

Nawiązujemy połączenie

Po stworzeniu obiektu musimy wstępnie otworzyć połączenie za pomocą metody open(typ, url, async).
Metoda ta przyjmuje 3 atrybuty: typ połączenia (get, post, put, patch, delete), adres do którego się łączymy (absolutny z http lub relatywny), oraz trzeci określający czy nasze połączenie ma być asynchroniczne.

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

Dla lepszych testów całość podłączmy pod kliknięcie na przycisk:


document.querySelector('#button').addEventListener('click', function() {
    let xhr = new XMLHttpRequest();

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

    //wysyłamy połączenie
    xhr.send();
});

Metoda send() w zależności od rodzaju połączenia wygląda nieco inaczej. Dla połączeń typu GET nie podaje się jej żadnych parametrów. Dla innych połączeń między nawiasami podajemy dane, które wysyłamy na serwer:


//GET
xhr.send()

//POST
xhr.send(formData);

Czekamy na odpowiedź

Nasze połączenie za pomocą 3 parametru ustawiliśmy na asynchroniczne. Jego wykonywanie nie przerywa działania dalszego skryptu. Połączenie jest nawiązywane, skrypt wykonuje się dalej, a gdy połączenie się zakończy, nasze dane są gotowe do użycia. Oznacza to, że nie możemy bezpośrednio pod linijką xhr.send() pobrać wyniku naszego połączenia.


xhr.send()
console.log(xhr.response); //brak danych

Aby wykryć moment kiedy dane pobrane z serwera są gotowe do użycia, musimy skorzystać z eventu nasłuchującego stan naszego połączenia.

Obiekt XMLHttpRequest udostępnia nam kilka takich eventów:

loadstart Rozpoczęcie wczytywania
progress Progres wczytywania
abort Anulowanie wczytywania
error Błąd wczytywania
load Zakończenie wczytywania, dane są gotowe
timeout Przekroczymy maksymalny czas połączenia
loadend Zakończenie wczytywania

W naszym przypadku użyjemy eventu load, który odpalany jest po otrzymaniu odpowiedzi:


document.querySelector('#button').addEventListener('click', function() {
    let xhr = new XMLHttpRequest();

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

    xhr.addEventListener('load', function() {
        console.log('Wynik połączenia:');
        console.log(this);
    })

    xhr.send();
});

Zdarzenie load odpalane jest gdy zakończy się połączenie. Takie połączenie może zakończyć się powodzeniem (status 200), lub jakimś niepowodzeniem (np status 404, 500 itp). Aby mieć pewność, że nasze połączenie zwróciło poprawne dane, musimy dodatkowo sprawdzić, czy właściwość status jest równa 200:


document.querySelector('#button').addEventListener('click', function() {
    let xhr = new XMLHttpRequest();

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

    xhr.addEventListener('load', function() {
        if (this.status === 200) {
            console.log('Wynik połączenia:');
            console.log(this);
        }
    })

    xhr.send();
});

Błąd połączenia

Kolejnym stanem, który chcemy obsłużyć jest błąd połączenia. Samo połączenie może nie zostać nawiązane (np przerwa w internecie), wtedy wykryjemy to eventem error:


(function() {
    document.querySelector('#buttonTest2').addEventListener('click', function() {
        let xhr = new XMLHttpRequest();

        //specjalnie dałem błędną ścieżkę
        xhr.open("GET", "https://jsonplaceholder.typicode.com/postsers", true);

        xhr.addEventListener('load', function() {
            if (this.status === 200) {
                console.log('Wynik połączenia:');
                console.log(this);
            }
        });

        xhr.addEventListener('error', function(e) {
            console.log('Wystąpił błąd połączenia')
        });

        xhr.send();
    });
})();

W większości przypadków połączenie zakończy się powodzeniem, ale zwróci nam wynik 404, 500 lub podobne statusy. Aby zareagować na taką sytuację, musimy w evencie load sprawdzić inne wartości statusu:


(function() {
    document.querySelector('#buttonTest2').addEventListener('click', function() {
        ...

        xhr.addEventListener('load', function() {
            if (this.status === 200) {
                console.log('Wszystko w porządku');
                console.log(this);
            } else {
                console.log('Połączenie zakończyło się statusem ' + this.status)
            }
        });

        ...
    });
})();

Postęp pobierania

Jeżeli chcemy sprawdzić postęp pobierania danych, użyjemy eventu progress:


xhr.addEventListener('progress', function(e) {
    console.log(e);
});

Jak widzisz event taki ma sporo właściwości. Nas najbardziej będą interesować właściwości loaded które określa ile danych już wysłaliśmy, oraz total, które określa całkowitą wielkość wysyłanych danych.

Aby mieć pewność, że jesteśmy w stanie obliczyć takie dane (czy event ma do nich dostęp, który może być zablokowany przez przeglądarkę), musimy skorzystać z właściwości e.lengthComputable:


...
xhr.addEventListener('progress', function(e) {
    if (e.lengthComputable) {
        const progress = (e.loaded / e.total) * 100;
        console.log(progress)
    }
});
...

Postęp wysyłania danych

Aby móc sprawdzić progress wysyłania danych, musimy skorzystać z właściwości upload, która wskazuje na instancję obiektu XMLHttpRequestUpload. Jest to obiekt bardzo podobny w użyciu do XMLHttpRequest, z tym że nie ma metody onreadystatechange.

Aby użyć tego obiektu wystarczy podobnie jak w XMLHttpRequest odwołać się do jego progresu:


...
xhr.upload.addEventListener('progress', function(e) {
    if (e.lengthComputable) {
        const progress = (e.loaded / e.total) * 100;
        console.log(progress)
    }
});
...

Zdarzenie readystatechange

Istnieje też starsza metoda reakcji na aktualny stan połączenia, która polega na skorzystaniu ze zdarzenia readystatechange:


(function() {
    document.querySelector('#buttonTest1').addEventListener('click', function() {
        let xhr = new XMLHttpRequest();

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

        xhr.addEventListener('readystatechange', function() {
            console.log(this);
        })

        xhr.send();
    });
})();

Jak widzisz w konsoli po kliknięciu na przycisk wynik wypisał się w konsoli 3 razy.

Zdarzenie readystatechange odpalane jest dla różnych stanów połączenia.

Żeby sprawdzić dany stan musimy sprawdzić właściwość readyState, która może przyjąć następujące wartości:

Wartość Opis
0 połączenie nie nawiązane
1 połączenie nawiązane
2 żądanie odebrane
3 przetwarzanie
4 dane zwrócone i gotowe do użycia

(function() {
    document.querySelector('#buttonTest1').addEventListener('click', function() {
        let xhr = new XMLHttpRequest();

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

        xhr.addEventListener('readystatechange', function() {
            if (this.readyState === 0) {
                console.log('Połączenie nienawiązane');
            }
            if (this.readyState === 1) {
                console.log('Połączenie z serwerem nawiązane');
            }
            if (this.readyState === 2) {
                console.log('Połączenie odebrane');
            }
            if (this.readyState === 3) {
                console.log('Połączenie przetwarzane');
            }
            if (this.readyState === 4) {
                console.log('Połączenie gotowe do użycia');
                console.groupCollapsed('Treść odpowiedzi');
                console.log(this.responseText);
                console.groupEnd();
            }
        });

        xhr.send();
    });
})();

W praktyce najczęściej interesuje nas readyState równe 4.
Dodatkowo by mieć pewność, że połączenie zwróciło zakończyło się powodzeniem, tak jak w poprzednich przykładach musimy sprawdzić status połączenia:


(function() {
    document.querySelector('#buttonTest1').addEventListener('click', function() {
        let xhr = new XMLHttpRequest();

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

        xhr.addEventListener('readystatechange', function() {
            if (this.readyState === 4 && this.status === 200) {
                console.groupCollapsed('Treść odpowiedzi');
                console.log(this.responseText);
                console.groupEnd();
            }
        });

        xhr.send();
    });
})();

Zwrócone dane

Po nawiązaniu połączenia, pozytywnym sprawdzeniu odpowiedzi, wreszcie możemy przystąpić do manipulowania zwróconymi danymi.
Zwrócone dane zawierają się równocześnie w 2 właściwościach: responseText i responseXML.

Pierwsza z nich zawiera dowolne dane w formie zwykłego tekstu. Druga zawiera dokument XML, który jest przerobiony na drzewo DOM. W większości przypadków nas będzie interesować responseText.

W poniższym przykładzie łączymy się z przykładowy API i pobieramy użytkowników. Odpowiedź jest w formacie JSON. Właściwość responseText ma format tekstu, więc musimy go zamienić na prawidłowy json za pomocą metody JSON.parse(), którą już poznaliśmy przy pracy z dataset.


document.querySelector('#button').addEventListener('click', function() {
    let xhr = new XMLHttpRequest();

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

    xhr.addEventListener('load', function() {
        if (this.status === 200) {
            const json = JSON.parse(this.responseText);
        }
    })

    xhr.send();
});

Gdy mamy już pobrane i przerobione dane, możemy zacząć na nich działać:


function insertPosts(data) {
    data.forEach(function(user) {
        console.groupCollapsed(user.id + ' ' + user.name);
        console.log(user);
        console.groupEnd();
    })
}

document.querySelector('#button').addEventListener('click', function() {
    let xhr = new XMLHttpRequest();

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

    xhr.addEventListener('load', function() {
        if (this.status === 200) {
            const users = JSON.parse(this.responseText);
            insertPosts(users);
        }
    })

    xhr.send();
});

Wysyłanie danych

Jeżeli wysyłamy dane do serwera, wysyłane są one za pomocą metody send() jako FormData, które podajemy jako parametr.


xhr.send('name=Marcin&surname=Nowak');

Skąd taki zapis tych danych? Sprawdźmy to na przykładowym formularzu. Formularz ma dwa pola - name i surname, które wysyłane są na adres ./odbierz.php metodą post.

Żeby sprawdzić jego działanie, przejdź do debugera do zakładki Network, a następnie włącz opcję Preserve log (dzięki temu konsola nie będzie się odświeżała po przejściu na nową stronę). Po ustawieniu tej opcji wypełnij i wyślij formularz.

Gdy w zakładce network wybierzesz stronę, która właśnie się wczytała (odbierz.php), po prawej stronie na samym dole będziesz miał właśnie nasze FormData.

Gdy teraz klikniesz na "view source" (tuż nad FormData) zobaczysz oryginalną postać tych danych, które zapisane są w jednym ciągu.

Tak właśnie są one wysyłane na serwer.
Nasz formularz jest formularzem html który działa klasycznie. Dzięki temu przeglądarka automatycznie za nas odpowiednio zamieniła wysyłane dane na FormData.
Gdy my chcemy wysłać takie dane za pomocą AJAX, musimy je zamienić ręcznie.

Bardzo ważne jest też, by przy wysyłaniu takich danych ustawić nagłówek Content-type na wartość application/x-www-form-urlencoded. Gdy tego nie zrobimy serwer dostanie nasze dane, ale nie będzie mógł ich wyłuskać. Ustawienie takiego nagłówka możemy zrobić za pomocą metody setRequestHeader():


xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");

Stwórzmy więc jeszcze jeden identyczny formularz i spróbujmy wysłać jego dane ajaxem:


<form action="./odbierz.php" method="post" class="form" id="formAjax">
    <fieldset>
        <div class="form-row">
            <label for="formName">Wpisz imię</label>
            <input type="text" required name="name" id="formName">
        </div>
        <div class="form-row">
            <label for="formSurname">Wpisz imię</label>
            <input type="text" required name="surname" id="formSurname">
        </div>
        <div class="form-row">
            <button type="submit" class="button">Wyślij</button>
        </div>
    </fieldset>
</form>

document.querySelector('#formAjax').addEventListener('submit', function(e) {
    e.preventDefault(); //przerywamy domyślną akcję formularza

    let xhr = new XMLHttpRequest();

    let nameVal = document.querySelector('#formName').value;
    let surnameVal = document.querySelector('#formSurname').value;

    //typ połączenia, url, czy asynchroniczen
    xhr.open("POST", "./odbierz.php", true);

    xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");

    xhr.addEventListener('load', function() {
        if (this.status === 200) {
            console.log(this.responseText);
        }
    });

    xhr.send('name='+nameVal+'&surname='+surnameVal);
});

Po wysłaniu formularza, sprawdź odpowiedź w konsoli. Możesz też wejść w zakładkę Network w debugerze, kliknąć odbierz.php i wybrać Response po prawej stronie.

Użycie powyższego zapisu dla send() jest trochę niewygodne w użyciu, dlatego najlepiej do konstrukcji takiego stringu z danymi użyć obiektu typu FormData().

Co ciekawe przy tym sposobie tworzenia zapisu danych nie potrzebujemy już ustawiać nagłówka Content-type:


document.querySelector('#formAjax').addEventListener('submit', function(e) {
    e.preventDefault(); //przerywamy domyślną akcję formularza

    let xhr = new XMLHttpRequest();

    let nameVal = document.querySelector('#formName').value;
    let surnameVal = document.querySelector('#formSurname').value;

    //typ połączenia, url, czy połączenie asynchroniczne
    xhr.open("POST", "./odbierz.php", true);

    //tego nagłówka już nie potrzebujemy
    //xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");

    xhr.addEventListener('load', function() {
        if (this.status === 200) {
            console.log(this.responseText);
        }
    });

    const formData = new FormData();
    formData.append('name', nameVal);
    formData.append('surname', surnameVal);

    xhr.send(formData);
});

XMLHttpRequest wraz z promisami

ES6 przyniosła nam promisy, dzięki którym możemy udoskonalić obsługę obiektu XMLHttpRequest. W powyższych kodach, kod który wykonujemy po zakończeniu requestu, musimy wykonywać z wnętrza obiektu XMLHttpRequest:


insertData(data) {
    ...
}

function loadData() {
    let xhr = new XMLHttpRequest();
    xhr.addEventListener('load', function() {
        insertData(this.responseText);
    });
}

loadData();
//szkoda, że nie możemy insertData wykonać tutaj, a musimy ją zagnieżdzać gdzieś w ustawieniach
//obiektu XMLHttpRequest

Dzięki promisom nasz kod możemy usprawnić:


function ajax(method, url) {
    return new Promise(function(resolve, reject) {
        const xhr = new XMLHttpRequest();
        xhr.open(method, url);
        xhr.addEventListener('load', function() {
            if (this.status === 200) {
                resolve(xhr.response);
            } else {
                reject({
                    status: this.status,
                    statusText: xhr.statusText
                });
            }
        });
        xhr.addEventListener('error', function() {
            reject({
                status: this.status,
                statusText: xhr.statusText
            });
        });

        xhr.send();
    });
}

function insertData(res) {
    console.log(res);
}

function loadData() {
    return ajax('GET', 'https://jsonplaceholder.typicode.com/users');
}


//skrypt właściwy, który tylko korzysta z funkcji klocków
loadData()
    .then(function(res) {
        insertData(res);
    }).catch(function(err) {
        console.error('Coś poszło nie tak');
    })