Konrad Kowalski (rootsher)Principal Platform & Reliability Architect000011111101000010111000011101111111001110100110

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.

text
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.

js
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:

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:

text
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.

js
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():

text
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:

js
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.

http
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:

js
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:

text
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:

js
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:

html
<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:

http
Access-Control-Allow-Origin: https://app.example

Przy żądaniach wykraczających poza to, co potrafi formularz, przeglądarka pyta najpierw:

http
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:

js
xhr.withCredentials = true;
http
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:

js
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().