AJAX: kiedy request przestał oznaczać przeładowanie strony
- data
- kategoria
- Frontend
- także w
- Backend · Networking
- czytanie
- 3 min / 568 słów
Formularz wiąże request HTTP z nawigacją dokumentu. Odpowiedź czyta przeglądarka: sprawdza status, sama podąża za redirectem i renderuje HTML.
AJAX rozdzielił te dwie rzeczy.
nawigacja:
request -> odpowiedź czyta przeglądarka -> nowy dokument
XHR:
request -> odpowiedź czyta JavaScript
Protokół pozostał ten sam. Zmienił się odbiorca odpowiedzi.
XMLHttpRequest jest klientem HTTP
Mechanizmem, który to umożliwił, był XMLHttpRequest.
const xhr = new XMLHttpRequest();
xhr.open("GET", "/api/users/42");
xhr.onload = () => {
const user = JSON.parse(xhr.responseText);
console.log(user);
};
xhr.send();
Do serwera trafia zwykły request HTTP:
GET /api/users/42 HTTP/1.1
Host: example.com
Accept: */*
Cookie: session=8f2c...
Warto zauważyć, czego w tym kodzie nie ma. Nie ma otwierania połączenia, doklejania ciasteczek, sprawdzania cache'a ani obsługi 3xx.
Request idzie tym samym stackiem sieciowym co nawigacja, więc dostaje dokładnie to samo: cookies origin, pulę połączeń, cache HTTP i automatyczne podążanie za redirectem.
XHR nie jest osobnym protokołem ani osobnym kanałem. Jest programowym wejściem do tego samego HTTP, którego przeglądarka używa do ładowania stron.
Status odpowiedzi przestał być sprawą przeglądarki
Przy nawigacji kody odpowiedzi obsługuje przeglądarka:
200 -> renderuje dokument
301 -> idzie pod nowy adres i zmienia pasek adresu
404 -> renderuje dokument błędu przysłany przez serwer
500 -> renderuje dokument błędu przysłany przez serwer
Przy XHR nie dzieje się nic z tej listy.
xhr.onload = () => {
if (xhr.status === 404) {
// to jest poprawnie zakończony request
}
};
onload znaczy „przyszła odpowiedź”, a nie „wszystko się udało”. onerror mówi o tym, że odpowiedzi nie udało się uzyskać: DNS, TLS, zerwane połączenie, zablokowanie przez politykę przeglądarki.
To rozróżnienie warto zapamiętać, bo przejdzie później w niezmienionej postaci do fetch():
odpowiedź 500 -> request się udał, aplikacja musi to sama zauważyć
brak odpowiedzi -> request się nie udał
Redirect jest wyjątkiem w drugą stronę. Przeglądarka nadal podąża za nim sama, a JavaScript widzi dopiero odpowiedź końcową i jej adres w xhr.responseURL.
Nagłówki żądania należą teraz do aplikacji
Przy nawigacji o nagłówkach decyduje przeglądarka. Przy XHR część z nich ustawia kod:
xhr.open("POST", "/api/users");
xhr.setRequestHeader("Accept", "application/json");
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(JSON.stringify({ name: "Alice" }));
To pozwala na coś, czego formularz nie potrafił: ten sam adres może obsłużyć nawigację i wywołanie z kodu, a o formacie odpowiedzi decyduje Accept.
Accept: text/html -> dokument dla nawigacji
Accept: application/json -> dane dla aplikacji
Klient przestał być anonimową przeglądarką i stał się konkretnym konsumentem formatu.
Nie wszystko wolno mu ustawić. Host, Cookie, Content-Length, Origin czy Connection należą do przeglądarki jako agenta i próba ich nadpisania jest po cichu ignorowana. Odpowiedź czyta się osobno:
xhr.getResponseHeader("Content-Type");
Cykl życia requestu stał się widoczny
Nawigacja też przechodzi przez etapy odpowiedzi, tylko nie ma ich komu pokazać. XHR wystawił je aplikacji:
0 UNSENT
1 OPENED
2 HEADERS_RECEIVED
3 LOADING
4 DONE
Między HEADERS_RECEIVED a DONE aplikacja wie już, jaki jest status i jakie są nagłówki, a body dopiero przychodzi.
Dzięki temu można było pokazać postęp wysyłki pliku albo przerwać żądanie w locie:
xhr.abort();
Przerwanie dotyczy tylko klienta. Serwer mógł już wykonać operację, o czym dalej w części o fetch().
Granica origin
Tu pojawia się problem, którego formularz nie miał.
Formularz zawsze mógł wysłać żądanie na obcy origin:
<form method="post" action="https://other.example/transfer">
Przeglądarka wyśle je razem z ciasteczkami tamtego serwisu. Właśnie dlatego istnieje CSRF.
Czego formularz nie potrafi: przeczytać odpowiedzi. Dokument zostaje podmieniony, ale skrypt strony nie ma dostępu do jego treści.
XHR daje dostęp programowy. Gdyby był nieograniczony, dowolna strona mogłaby wykonać request do cudzej poczty na ciasteczkach użytkownika i odczytać wynik. Dlatego odpowiedź cross-origin jest domyślnie niedostępna, a zgody udziela serwer:
Access-Control-Allow-Origin: https://app.example
Przy żądaniach wykraczających poza to, co potrafi formularz, przeglądarka pyta najpierw:
OPTIONS /api/users/42 HTTP/1.1
Origin: https://app.example
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: content-type
Dopiero po zgodzie wysyła właściwy DELETE.
Ciasteczka cross-origin też trzeba włączyć jawnie po obu stronach:
xhr.withCredentials = true;
Access-Control-Allow-Credentials: true
Model bezpieczeństwa przesunął się razem z odbiorcą odpowiedzi. Przy nawigacji granicą było to, co widzi użytkownik. Przy XHR granicą jest to, co może przeczytać skrypt.
Dlaczego XHR było niewygodne
API XMLHttpRequest rosło przez lata i ma model oparty na mutable object + events:
xhr.open(...);
xhr.setRequestHeader(...);
xhr.onload = ...;
xhr.onerror = ...;
xhr.send(...);
Do tego dochodziły:
readyState,- callbacki,
- osobne eventy,
- nieintuicyjna obsługa odpowiedzi,
- historyczny bagaż kompatybilności.
Request, response, nagłówki i stan transferu siedzą w jednym obiekcie, który zmienia się w czasie. Nie da się takiej odpowiedzi przekazać dalej ani złożyć z kilku warstw.
Problem nie był już w możliwościach.
Problemem był interfejs programistyczny.
Browser potrzebował API, w którym request i response są zwykłymi wartościami, a nie stanem jednego obiektu.
Tak powstał fetch().