Fetch API

Fetch API

Wprowadzenie

Fetch API jest interfejsem pozwalającym na asynchroniczne pobieranie zasobów. Zanim powstało Fetch API, aby pobrać zasób z serwera, korzystało się z obiektu XMLHttpRequest (XHR). Fetch API powstało, aby uprościć sposób komunikacji z serwerem, a dzięki wykorzystaniu obietnic, bardziej odpowiada dzisiejszym standardom programowania i zapobiega wielokrotnym wywołaniom zwrotnym.

Zobaczmy jak wyglądała komunikacja z serwerem przed powstaniem Fetch API.

Większość przeglądarek w tamtych czasach wykorzystywała obiekt XMLHttpRequest w celu pobierania zasobów z serwera. Większość, to niestety nie znaczy wszystkie. Microsoft w Internet Explorer zaimplementował ten standard jako ActiveXObject co spowodowało, że jeśli chcieliśmy, aby nasz program działał zarówno na IE jak też na Safari czy pod Netscape, należało wspierać oba te rozwiązania.

var request;
if (window.XMLHttpRequest) { //Netscape, Safari, ...
  request = new XMLHttpRequest();
} else if (window.ActiveXObject) { //IE
  try {
    request = new ActiveXObject('Msxml2.XMLHTTP');
  } catch (e) {
    try {
      request = new ActiveXObject('Microsoft.XMLHTTP');
    } catch (e) {}
  }
}

Mając utworzony obiekt request, mogliśmy następnie wysłać żądanie do serwera.

request.open('GET', 'https://jsonplaceholder.typicode.com/posts/1', true);
request.send();
request.onreadystatechange = function() {
    if(request.readyState === 4) {
        if(request.status === 200) {    
            console.log(request.responseText)
        }
    }
};

https://jsfiddle.net/gz5exu5x/

W powyższym przykładzie posłużyliśmy się EventHandlerem o nazwie onreadystatechange, który wywoływany jest za każdym razem gdy, zmieni się stan readyState. XMLHttpRequest.readyState może odpowiedzieć jednym z pięciu stanów od 0 do 4. Dzięki temu mogliśmy sprawdzić na jakim etapie jest przetwarzanie naszego żądania. Stan oznaczony cyfrą 4 (Done) informował że żądanie zostało zakończone i można przejść do zbadania jakim statusem HTTP odpowiedział serwer. W powyższym przykładzie sprawdzamy czy żądanie zakończyło się statusem 200 (OK). Jeśli tak, to przystępujemy do wypisania otrzymanych danych na konsoli.

Ponieważ korzystanie z tak niskopoziomowego API nie należało do najwygodniejszych, powstało wiele bibliotek opakowujących XHR. Do najpopularniejszych należały rozwiązania z biblioteki Jquery:

$.ajax('https://jsonplaceholder.typicode.com/posts/1', {
  success: (data) => {
    console.log(JSON.stringify(data));
  },
});

https://jsfiddle.net/3g4ww3t2/

Rezultat działania powyższego skryptu jest dokładnie taki sam, jak poprzednio przedstawionego rozwiązania z użyciem obiektu XMLHttpRequest. Jednakże zapis jest dużo bardziej przyjemny. Nie zmienia to jednak faktu, że metoda ajax() jest jedynie metodą opakowującą. Nadal całe wywołanie odbywa się za pomocą obiektu XMLHttpRequest.

Fetch API

Przez ponad dekadę użycie obiektu XMLHttpRequest było jedynym sposobem na stworzenie asynchronicznego żądania w JavaScript. Pomimo ogromnej użyteczności, używanie XHR nie należało do najprzyjemniejszych. Podstawowe wady tego rozwiązania to:

  • brak separacji zagadnień (Separation of Concern),
  • stan żądania oraz jego input i output były zarządzane poprzez interakcje z jednym obiektem,
  • użycie Eventów do śledzenia stanu obiektu odstaje od obecnych standardów skupionych wokół obietnic.

Fetch API rozwiązuje większość problemów związanych z użyciem XHR, a ponieważ korzysta z obietnic, tym samym zapobiega wielokrotnym wywołaniom zwrotnym zwanym „callback hell”. Fetch API wprowadza do języka JavaScript obiekty Request, Response, Headers i Body, których nazwy dobrze znamy z protokołu HTTP.

Metoda fetch()

Podstawą pracy z Fetch API jest użycie metody fetch() do asynchronicznego pobierania danych z serwera. Zobaczmy jak to działa na najprostszym przykładzie.

fetch('https://jsonplaceholder.typicode.com/posts/1')
  .then(response => response.json())
  .then(json => console.log(json));

https://jsfiddle.net/j1vs64vx/

W przykładzie powyżej, metoda fetch() jako parametr przyjmuje adres URL do zdalnego zasobu. Następnie po wykonaniu żądania, odpowiedź zamienia na obiekt typu json i wypisuje na konsolę.

Oczywiście oprócz pobierania danych metodą GET, możemy także komunikować się za pomocą różnych innych metod HTTP, np.: POST, PUT, DELETE. Przykład poniżej pokazuje jak wysłać żądanie przy użyciu metody POST.

const data = {
    title: 'Test title',
    body: 'Test body',
    userId: 42
}

const options = {
   method: 'POST',
   body: JSON.stringify(data),
   headers: {
       'Content-Type': 'application/json'
   }
};

fetch('https://jsonplaceholder.typicode.com/posts', options)
  .then(response => response.json())
  .then(json => console.log(json));

https://jsfiddle.net/6dhqcjop/

Na początku tworzymy obiekt data z danymi, które chcemy wysłać. Następnie w obiekcie options definiujemy rodzaj metody HTTP, w naszym przypadku POST, a do zmiennej body przypisujemy wcześniej utworzony obiekt data. Podobnie jak w poprzednim przykładzie, też jako pierwszy parametr podajemy adres URL, pod który będziemy wysyłać dane. Drugim parametrem będzie nasz obiekt options na podstawie którego, metoda fetch() określi jaką metodę HTTP ma użyć do wysłania danych, z jakimi danymi i jakimi nagłówkami. W odpowiedzi serwis jsonplaceholder zwróci nam następujący obiekt, który wypiszemy na konsoli.

{
  "title": "Test title",
  "body": "Test body",
  "userId": 42,
  "id": 101
}

Fetch() + async/await

Ponieważ metoda fetch() zawsze zwróci nam obietnicę, świetnie się nam to wpisuje w konwencję async/await. Dzięki temu, możemy sprawić że nasz kod będzie bardziej czytelny i łatwiejszy do zrozumienia. Jeśli temat async/await nie jest Ci znany, zachęcam do zapoznania się z artykułem na ten temat.

async function fetchUsers(url) {
  const response = await fetch(url);
  const json = await response.json();
  const listOfusernames = json.map(user => user.username);

  console.log(listOfusernames);
}

fetchUsers('https://jsonplaceholder.typicode.com/users');

https://jsfiddle.net/9nro6dcr/

W odpowiedzi na konsoli powinniśmy zobaczyć tablicę z imionami użytkowników.

[
  "Bret",
  "Antonette",
  "Samantha",
  "Karianne",
  "Kamren",
  "Leopoldo_Corkery",
  "Elwyn.Skiles",
  "Maxime_Nienow",
  "Delphine",
  "Moriah.Stanton"
]

Przechwytywanie błędów

Zazwyczaj mamy nadzieje że nasza komunikacja z serwerem zakończy się sukcesem i otrzymamy dane, których się spodziewaliśmy. Czasami jednak, z różnych przyczyn, wysłanie żądania do serwera nie zwraca nam zadowalającej odpowiedzi. Przeanalizujmy scenariusz, w którym zasób jest nieosiągalny a API zwraca nam status 404.

Na początek stwórzmy zapytanie do API podając błędny adres i spróbujmy wychwycić błąd za pomocą metody catch().

fetch('https://api.github.com/users/dariuszwzresien/repos')
  .then(data => console.log('dane z serwera: ', data))
  .catch(error => console.log('błąd: ', error));

https://jsfiddle.net/f5v1cfuL/

Jak się jednak okaże, catch() nie został wywołany, a dane zostały obsłużone w taki sposób, jakby całe zapytanie zostało poprawnie wykonane. Zobaczmy więc jakie dane otrzymaliśmy.

​{
bodyUsed: false
headers: Headers {}
ok: false
redirected: false
status: 404
statusText: "Not Found"
type: "cors"
url: "https://api.github.com/users/dariuszwzresien/repos"
}
​

Dlaczego metoda fetch() nie przechwyciła informacji o statusie błędu 404? Jak możemy przeczytać w dokumentacji, metoda fetch() zakończy się jako promise.reject() z błędem TypeError w przypadku braku możliwości dostania się do żądanej strony np. z powodu błędnej nazwy domeny lub z przyczyn bezpieczeństwa.

fetch('https://api.guthub.com/users/dariuszwrzesien/repos') //literówka w nazwie domeny api.guthub.com
  .then(response => response.json())
  .then(data => console.log('dane z serwera: ', data))
  .catch(error => console.log('błąd: ', error));

https://jsfiddle.net/2dsyyjLm/

W tym przypadku nasze żądanie zostanie obsłużone przez metodę catch() i w konsoli zostanie wypisany komunikat z błędem:

błąd: TypeError: NetworkError when attempting to fetch resource.

Jeśli jednak chcielibyśmy aby metoda fetch(), reagowała wywołaniem metody catch() w przypadku otrzymania statusu HTTP 404, możemy sami wymusić wywołanie promise.reject().

Najpierw napiszmy metodę, która w odpowiedni sposób zareaguje na otrzymaną odpowiedź z serwera. Metoda handleResponse() na podstawie otrzymanej odpowiedzi sprawdzi czy status jest równy 404 i jeśli tak, to odrzuci obietnice, powodując tym samym wywołanie metody catch() w łańcuchu wywołań fetch().

function handleResponse(response) {
  if (response.status === 404) {
    return Promise.reject(response);
  }
  return response.json();
}

fetch('https://api.github.com/users/dariuszwzresien/repos')
  .then(handleResponse)
  .then(data => console.log('dane z serwera: ', data))
  .catch(error => console.log('błąd: ', error));

Obiekty Fetch API

Wspomniałem wcześniej że Fetch API oprócz samej metody fetch() wprowadziło do języka JavaScript obiekty Response, Request i Headers. Przyjrzyjmy się zatem każdemu z nich.

Response

Jak możemy zauważyć w przykładzie poniżej, rezultatem jaki zwróci nam metoda fetch() nie są tylko dane wystawione przez serwer, ale cały obiekt Response, w którym znajdziemy informację na temat żądania i odpowiedzi. Oprócz tego, że obiekt Response jest tworzony przez metodę fetch(), mamy też możliwość utworzyć go z poziomu JavaScript. Z tej możliwości skorzystamy głównie podczas pracy z Service Workerami. Więcej na temat Service Workerów możesz przeczytać w artykule o Progressive Web Apps.

Przyjrzyjmy się z bliska jak zbudowany jest obiekt Response, który otrzymujemy z wywołania metody fetch().

async function fetchUsers(url) {
  const response = await fetch(url);
  console.log(response);
}

fetchUsers('https://jsonplaceholder.typicode.com/users');

https://jsfiddle.net/nfmoLn8a/

Na konsoli zostanie wypisany następujący obiekt:

Response {
type: "cors", 
url: "https://jsonplaceholder.typicode.com/users", 
redirected: false, 
status: 200, 
ok: true, 
body: ReadableStream, 
bodyUsed: false, 
headers: Headers
}

Obiekt Response składa się między innymi z następujących właściwości:

headers

Zmienna headers jest obiektem typu Headers, który udostępnia nam między innymi metody has() oraz get(), za pomocą których, możemy sprawdzić nagłówki otrzymanej odpowiedzi z serwera.

async function fetchUsers(url) {
  const response = await fetch(url);
  console.log(response.headers.has('Content-Type')); //true
  console.log(response.headers.get('Content-Type')); //application/json; charset=utf-8
}

fetchUsers('https://jsonplaceholder.typicode.com/users');

https://jsfiddle.net/13oyd340/

status/statusText

Status zwraca nam kod odpowiedzi HTTP, a statusText jego reprezentację tekstową. Więcej informacji na ten temat statusów i kodów odpowiedzi możesz znaleźć na stronie wikipedii.

body

Aby dostać się do zawartości parametru body możemy użyć metody: text(), json() lub blob(). Różnica między nimi jest taka że metoda text() zwróci nam obiekt w postaci USVString, a metoda json() obiekt w postaci JSON. Natomiast metodę blob() używamy w przypadku otrzymania np. plików z grafiką (jpg, png itp.).

(async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/users/1')
  const json = await response.json()
  console.log(json);
})();

Wynikiem działania powyższego kodu jest wypisanie na konsoli właściwości body w formacie JSON.

{
  "id": 1,
  "name": "Leanne Graham",
  "username": "Bret",
  "email": "[email protected]",
  "address": {
    "street": "Kulas Light",
    "suite": "Apt. 556",
    "city": "Gwenborough",
    "zipcode": "92998-3874",
    "geo": {
      "lat": "-37.3159",
      "lng": "81.1496"
    }
  },
  "phone": "1-770-736-8031 x56442",
  "website": "hildegard.org",
  "company": {
    "name": "Romaguera-Crona",
    "catchPhrase": "Multi-layered client-server neural-net",
    "bs": "harness real-time e-markets"
  }
}

https://jsfiddle.net/4c01rtuc/

type

Obiekt Response może posiadać jeden z następujących typów:

  • basic – typ podstawowy, spełniający zasadę same-origin z dostępnymi wszystkimi nagłówkami oprócz “Set-Cookie” i “Set-Cookie2”,
  • cors – gdy odpowiedź została zwrócona z żądania cross-origin z nagłówkami CORS, czyli na przykład strona zażądała danych z serwera, który nie znajduje się w tej samej domenie. Dodatkowo serwer ma możliwość dzielenia się zasobami za pomocą specyfikacji CORS (więcej na ten temat można przeczytać na stronie enable-cors.org) . Lista dostępnych nagłówków ograniczona jest do `Cache-Control`, `Content-Language`, `Content-Type`, `Expires`, `Last-Modified`, and `Pragma`,
  • opaque – tego typu odpowiedź zwracana jest gdy komunikacja odbywa się pomiędzy różnymi domenami lecz bez użycia nagłówków CORS.

Request

Request pozwala nam w sposób obiektowy zarządzać danymi, które chcemy przesłać w żądaniu. Spróbujmy zatem za pomocą poznanej wcześniej metody fetch() oraz obiektu Request wykonać żądanie do serwera.

Najpierw skonfigurujmy sobie obiekt Request aby odpowiadał naszym potrzebom.

const options = { 
    method: 'GET',
    mode: 'cors',
    cache: 'default'
};

Powyżej zadeklarowaliśmy z jakiej metody HTTP będzie korzystał nasz obiekt Request (method: 'GET’). Dodatkowo ustawiliśmy parametr mode na cors, w przypadku przeglądarki Firefox cors jest trybem domyślnym. Inaczej się to ma jednak w przypadku Chrome, gdzie do wersji Chrome 47 domyślnym trybem był no-cors, a od wersji Chrome 47 (włącznie) domyślnym trybem jest same-origin. Zmienna cache ustawiona na default informuje przeglądarkę że ma pobrać zasób jeśli w pamięci cache nie znalazła pasującego do zapytania elementu. Jeśli zasób trzymany w cache nie jest aktualny przeglądarka wykonana tzw. zapytanie warunkowe (conditional request) i na jego podstawie zdecyduje, czy pobrać nowy zasób czy skorzystać z tego, który posiada w cache.

Gdy mamy już stworzoną konfigurację dla obiektu Request możemy spróbować go utworzyć i przekazać do metody fetch()

const request = new Request('https://placekitten.com/200/200', options);
const image = document.querySelector('img');

fetch(request).then(function(response) {
  return response.blob();
}).then(function(response) {
  image.src = URL.createObjectURL(response);
});

Jak widać powyżej, parametrami wejściowymi dla nowo utworzonego obiektu Request, może być adres URL zasobu zdalnego oraz wcześniej przygotowany obiekt options. Tak przygotowany Request następnie przekazujemy do metody fetch(). Dalsza część jest już nam znana, otrzymujemy odpowiedź z serwera, a ponieważ nasze żądanie dotyczyło pobrania pliku z grafiką, użyjemy metody blob() w celu wyciągnięcia jej z obiektu Body.

Headers

Wspomniałem już o obiekcie Headers w kontekście omawiania obiektu Response. Oprócz wspomnianych metod has() i get() obiekt ten posiada jeszcze kilka metod ułatwiających pracę z nagłówkami m. in.: append(), delete(), set() itd. Na uwagę zasługuje fakt, że każda z metod rzuci nam błąd TypeError, gdy będziemy próbować przekazać do niej niepoprawną nazwę nagłówka. Listę poprawnych nazw możecie znaleźć tutaj.

Podsumowanie

Fetch API stało się standardem już w 2015 roku, dzięki czemu otrzymaliśmy całkiem przyjemne API, umożliwiające asynchroniczne pobieranie zasobów z sieci, a wprowadzone wraz z Fetch API obiekty, pomagają nie tylko w pracy z samym API ale także są istotnym elementem w pracy z Service Workerami.

Wprowadzony standard rozwiązuję większość problemów, z którymi borykaliśmy się podczas pracy z XHR, jednakże nie jest wolny od wad. Najczęściej w tym kontekście mówi się o utrudnionym przechwytywaniu błędów HTTP, braku możliwości śledzenia postępu oraz braku możliwości przerwania uruchomionego procesu fetch(). Jeśli chodzi o ten ostatni problem to na stronie Mozilli można znaleźć propozycję rozwiązania za pomocą AbortSignal, niestety rozwiązanie to jest nadal w fazie eksperymentu.

Jeśli jednak zostanie zaimplementowane praca z nim może wyglądać następująco.

const controller = new AbortController();
const signal = controller.signal;

const downloadBtn = document.querySelector('.download');
const abortBtn = document.querySelector('.abort');

downloadBtn.addEventListener('click', fetchVideo);

abortBtn.addEventListener('click', function() {
  controller.abort();
  console.log('Download aborted');
});

function fetchVideo() {
  ...
  fetch(url, {signal}).then(function(response) {
    ...
  }).catch(function(e) {
    reports.textContent = 'Download error: ' + e.message;
  })
}

Gdy uruchomiona zostanie metoda abort(), metoda fetch() zakończy się jako promise.reject() z błędem AbortError.

Fetch API jest wspierane przez wszystkie nowoczesne przeglądarki, jeśli zaszła by jednak konieczność użycia na niewspieranej platformie – można użyć polifill.