Fetch ja asynkroniset funktiot

Javascriptin normaalit funktiot suoritetaan synkronisesti eli peräkkäin: seuraava rivi alkaa vasta kun edellinen on valmis. Verkkopyyntö ei voi toimia näin, koska palvelimen vastausta voi joutua odottamaan sekunteja. Jos selain jäisi odottamaan, koko sivu jäätyisi siksi aikaa.

Siksi verkkopyynnöt tehdään asynkronisesti fetch-rajapinnalla: koodi käynnistää pyynnön ja jatkaa eteenpäin, ja vastaukseen reagoidaan vasta kun se saapuu.

Asynkroninen ei tarkoita rinnakkaista

Tämä kannattaa ymmärtää heti alussa, koska väärä mielikuva aiheuttaa myöhemmin sekaannusta. Javascript on yksisäikeinen. Asynkroninen funktio ei suoriudu rinnakkain muun koodin kanssa — sen suoritus keskeytyy odotuskohdassa ja jatkuu myöhemmin siitä mihin se jäi.

Rinnakkain tapahtuu vain odottaminen: verkkopyynnön hoitaa selain, ei javascript-koodi. Tästä seuraa kaksi asiaa:

Promise

Asynkroniset funktiot palauttavat Promise-objektin eli lupauksen siitä, että tulos saadaan joskus. Lupaus on aina yhdessä kolmesta tilasta:

TilaMerkitysFetch-pyynnössä
pendingtekeilläpyyntö on lähetetty, vastausta odotetaan
fulfilledonnistuipalvelin vastasi
rejectedepäonnistuiverkkovirhe tai yhteyttä ei saatu

Lupauksia käsitellään kahdella tavalla: ketjuttamalla .then()-kutsuja tai käyttämällä async- ja await-avainsanoja. Käytä async/await-tapaa; se on luettavampi ja sen virheenkäsittely on tavallinen try-lohko. Ketjutettu muoto on hyvä tuntea, koska sitä näkee paljon.

Kaksi sääntöä, jotka selittävät lähes kaiken:

Yksinkertaisin esimerkki

Esimerkeissä käytetään JSONPlaceholder-palvelua, joka tarjoaa testidataa ja sallii pyynnöt mistä tahansa osoitteesta. Kokeile esimerkkejä selaimen konsolissa.

async function haeKayttajat() {
  // await odottaa, että palvelin vastaa
  const vastaus = await fetch("https://jsonplaceholder.typicode.com/users");

  // vastauksen runko luetaan erikseen, tästä lisää alempana
  const kayttajat = await vastaus.json();

  console.log(kayttajat);
}

haeKayttajat();

Sama ketjutettuna. Toiminta on identtinen, mutta rakenne on hankalampi lukea:

fetch("https://jsonplaceholder.typicode.com/users")
  .then(vastaus => vastaus.json())
  .then(kayttajat => console.log(kayttajat));

Miksi vastaus luetaan kahdessa vaiheessa?

Ensimmäinen await odottaa, että palvelimelta saapuvat otsakkeet — tilakoodi, sisältötyyppi ja muut tiedot. Runko voi olla megatavuja ja se saapuu virtana sen jälkeen, joten se luetaan erikseen omalla kutsullaan.

Siksi fetch palauttaa Response-objektin, ja rungon lukevat metodit (json(), text()) palauttavat kukin oman lupauksensa. Tämä on myös syy siihen, että await-sanoja tarvitaan kaksi.

Fetchin vaiheet

Seuraava kaavio kokoaa yhteen sen, mitä yhden fetch-kutsun aikana tapahtuu. Aika kulkee ylhäältä alas. Yhtenäiset nuolet ovat varsinaista liikennettä, katkoviivat lupauksia, jotka palautuvat javascript-koodille.

Fetch-kutsun vaiheet javascript-koodin, selaimen ja palvelimen välillä Vuokaavio kolmella pystysuoralla radalla: javascript-koodi, selain ja palvelin. Aika kulkee ylhäältä alas. Ensin javascript-koodi kutsuu fetch-funktiota, ja selain palauttaa heti keskeneräisen lupauksen, jolloin koodin suoritus jatkuu. Selain lähettää HTTP-pyynnön palvelimelle. Palvelin palauttaa ensin vastauksen otsakkeet, jolloin selain täyttää ensimmäisen lupauksen ja javascript-koodi saa Response-objektin. Tässä kohtaa tarkistetaan vastaus.ok. Sen jälkeen javascript-koodi kutsuu vastaus.json-metodia, palvelimelta saapuu vastauksen runko, ja selain täyttää toisen lupauksen, jolloin jäsennetty data on käytettävissä. javascript-koodi selain palvelin fetch(osoite) Promise (pending) HTTP-pyyntö vastauksen otsakkeet Response-objekti vastaus.json() vastauksen runko jäsennetty data suoritus jatkuu heti eteenpäin 1. await valmis tarkista vastaus.ok tässä 2. await valmis data on käytettävissä
Fetch-kutsun vaiheet. Kaksi katkoviivanuolta vasemmalle vastaavat kahta await-sanaa koodissa.

Kaaviosta näkyy kolme asiaa, jotka selittävät fetchin käyttäytymisen:

Huomaa myös, mitä kaaviossa ei ole: mikään nuoli ei kerro virheestä. Palvelimen vastaus 404 kulkee kaaviossa täsmälleen samaa reittiä kuin onnistunut vastaus, ja lupaus täyttyy normaalisti. Vain katkennut yhteys estää nuolen palaamasta — vasta silloin lupaus hylätään. Tästä lisää luvussa Virheenkäsittely.

Suoritusjärjestys

Aja tämä konsolissa ja katso, missä järjestyksessä rivit tulostuvat:

console.log("1. ennen pyyntöä");

fetch("https://jsonplaceholder.typicode.com/users/1")
  .then(vastaus => vastaus.json())
  .then(kayttaja => console.log("3. data saapui:", kayttaja.name));

console.log("2. heti pyynnön jälkeen");
1. ennen pyyntöä
2. heti pyynnön jälkeen
3. data saapui: Leanne Graham

Rivi 2 tulostuu ennen rivia 3, vaikka se on koodissa myöhemmin. Pyyntö käynnistetään, suoritus jatkuu heti eteenpäin, ja vasta kun vastaus saapuu, käsittelijä pääsee vuoroon.

Sama pätee async-funktioon: haeKayttajat() palaa heti kutsujalle, ja funktio jatkaa taustalla.

console.log("ennen");
haeKayttajat();                    // ei odota valmistumista
console.log("jälkeen");            // tämä tulostuu ennen haun tulosta

Huomaa, että Firefoxin konsoli merkitsee fetch-pyynnöt tunnuksella XHR GET. Nimi tulee vanhasta XMLHttpRequest-rajapinnasta, jota fetch on korvaamassa.

Virheenkäsittely

Tämä on fetchin tärkein yksittäinen asia, ja se yllättää lähes kaikki:

Fetch ei hylkää lupausta HTTP-virhekoodeista. Vastaus 404 tai 500 tuottaa täysin onnistuneen lupauksen. Vain verkkovirhe — yhteys katkeaa, palvelinta ei löydy, CORS estää pyynnön — hylkää lupauksen.

Tilanne on siis tarkistettava itse vastaus.ok-ominaisuudesta, joka on true vain kun tilakoodi on välillä 200–299:

async function haeKayttaja(id) {
  try {
    const vastaus = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);

    // ILMAN TÄTÄ 404-virhesivu päätyisi dataksi asti
    if (!vastaus.ok) {
      throw new Error(`HTTP-virhe ${vastaus.status} ${vastaus.statusText}`);
    }

    return await vastaus.json();
  }
  catch (virhe) {
    console.error("Haku epäonnistui:", virhe.message);
    return null;
  }
}

// kokeile molemmat: id 1 on olemassa, id 9999 ei
console.log(await haeKayttaja(1));
console.log(await haeKayttaja(9999));

try-lohko nappaa kolme eri asiaa: verkkovirheen, itse heitetyn HTTP-virheen ja json()-metodin heittämän jäsennysvirheen. Viimeinen tulee vastaan juuri silloin kun palvelin palauttaa virhesivun HTML-muodossa eikä JSONia.

Jos käytät ketjutettua muotoa, virheet napataan .catch()-metodilla:

fetch(osoite)
  .then(vastaus => {
    if (!vastaus.ok) {
      throw new Error(`HTTP-virhe ${vastaus.status}`);
    }
    return vastaus.json();
  })
  .then(data => console.log(data))
  .catch(virhe => console.error(virhe.message));

Muista virheenkäsittely myös kutsuttaessa. Pelkkä haeKayttaja(1); ilman await- tai .catch()-käsittelyä tuottaa käsittelemättömän lupaushylkäyksen, joka näkyy konsolissa mutta jää ohjelmalta huomaamatta.

Datan palauttaminen funktiosta

Tavallinen kompastuskivi: funktio hakee datan, mutta kutsuja ei saa sitä käsiinsä.

// EI TOIMI: data jää funktion sisään
async function hae() {
  const vastaus = await fetch(osoite);
  const data = await vastaus.json();
  // ei return-lausetta
}

const tulos = hae();
console.log(tulos);      // Promise { <pending> } - ei dataa

Koska async-funktio palauttaa aina Promisen, myös paluuarvo on purettava:

async function hae() {
  const vastaus = await fetch(osoite);
  return await vastaus.json();      // data palautetaan
}

// async-funktiossa tai moduulin ylätasolla:
const data = await hae();

// muualla:
hae().then(data => console.log(data));

JSON ja XML

Response-objektilla on useita metodeja rungon lukemiseen. Tärkeimmät ovat json(), text() ja blob(). Kutakin voi kutsua vain kerran: runko on virta, joka luetaan läpi yhden kerran.

JSON

json() lukee rungon ja jäsentää sen suoraan javascript-objektiksi tai -taulukoksi. Tämä on se muoto, jota viikkotehtävissä käytetään.

const vastaus = await fetch("https://jsonplaceholder.typicode.com/posts?userId=1");
const postaukset = await vastaus.json();

console.log(postaukset.length);        // 10
console.log(postaukset[0].title);

for (const postaus of postaukset) {
  console.log(`${postaus.id}: ${postaus.title}`);
}

json() heittää poikkeuksen, jos runko ei ole kelvollista JSONia. Siksi se kuuluu try-lohkon sisään.

XML

XML-muotoiselle datalle ei ole omaa metodia. Runko luetaan tekstinä text()-metodilla ja jäsennetään DOMParser-luokalla. Tuloksena on tavallinen Document, jota käsitellään samoilla DOM-metodeilla kuin www-sivua — querySelector, getAttribute, textContent ja niin edelleen.

Esimerkkinä käytetään Wikipedian rajapintaa, joka osaa palauttaa hakutulokset XML-muodossa ja on vapaasti käytettävissä. Rajapinta sallii pyynnöt mistä tahansa lähteestä, kun osoitteeseen lisätään parametri origin=* — tästä lisää luvussa CORS javascript-ohjelmoijan näkökulmasta.

Osoitteen kyselyosa kannattaa koota URLSearchParams-objektilla, joka hoitaa merkkien koodauksen puolestasi:

async function haeXml(osoite) {
  const vastaus = await fetch(osoite);

  if (!vastaus.ok) {
    throw new Error(`HTTP-virhe ${vastaus.status}`);
  }

  const teksti = await vastaus.text();
  const jasennin = new DOMParser();
  const xml = jasennin.parseFromString(teksti, "application/xml");

  // DOMParser EI heitä poikkeusta virheellisestä XML:stä, vaan palauttaa
  // dokumentin, jossa on parsererror-elementti. Tämä on tarkistettava itse.
  const virhe = xml.querySelector("parsererror");
  if (virhe !== null) {
    throw new Error(`Virheellinen XML: ${virhe.textContent}`);
  }

  return xml;
}

const parametrit = new URLSearchParams({
  action: "query",
  list: "search",
  srsearch: "javascript",
  srlimit: "5",
  format: "xml",
  origin: "*"            // sallii pyynnön mistä tahansa lähteestä
});

const xml = await haeXml(`https://fi.wikipedia.org/w/api.php?${parametrit}`);

Vastaus on jotakin tällaista:

<?xml version="1.0"?>
<api>
  <query>
    <search>
      <p ns="0" title="JavaScript" size="24601" wordcount="3020" />
      <p ns="0" title="ECMAScript" size="5312" wordcount="620" />
    </search>
  </query>
</api>

Tulokset luetaan tutuilla DOM-metodeilla. Huomaa, että elementin nimi on p — se on MediaWikin oma nimi hakutulokselle eikä sillä ole mitään tekemistä HTML:n kappale-elementin kanssa:

for (const osuma of xml.querySelectorAll("search p")) {
  const otsikko = osuma.getAttribute("title");
  const sanoja = osuma.getAttribute("wordcount");
  console.log(`${otsikko} (${sanoja} sanaa)`);
}

Toisena parametrina annettava tyyppi kannattaa olla "application/xml". Arvo "text/html" jäsentää sisällön HTML-sääntöjen mukaan, jolloin muotovirheet korjaillaan hiljaisesti eikä parsererror-elementtiä synny.

Sama toimii tietysti myös omalla palvelimella olevaan XML-tiedostoon. Silloin osoite on suhteellinen eikä CORSia tarvita lainkaan:

const xml = await haeXml("kayttajat.xml");

Rinnakkaiset pyynnöt

Tässä sana "rinnakkain" on paikallaan: useita pyyntöjä voi olla matkalla yhtä aikaa, vaikka javascript-koodi ajetaankin yhdessä säikeessä.

Varo await-sanaa silmukassa. Se odottaa jokaisen pyynnön valmistumista ennen seuraavan aloittamista, jolloin kymmenen pyyntöä kestää kymmenkertaisen ajan:

// HIDAS: pyynnöt tehdään peräkkäin
const postaukset = [];
for (const id of [1, 2, 3, 4, 5]) {
  const vastaus = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
  postaukset.push(await vastaus.json());
}

Promise.all käynnistää kaikki pyynnöt heti ja odottaa, kunnes viimeinenkin on valmis:

async function haeJson(osoite) {
  const vastaus = await fetch(osoite);
  if (!vastaus.ok) {
    throw new Error(`HTTP-virhe ${vastaus.status}`);
  }
  return await vastaus.json();
}

// NOPEA: kaikki pyynnöt yhtä aikaa
const postaukset = await Promise.all(
  [1, 2, 3, 4, 5].map(id => haeJson(`https://jsonplaceholder.typicode.com/posts/${id}`))
);

console.log(postaukset.map(p => p.title));

Sama toimii myös eri osoitteille, kun tarvitaan useaa eri aineistoa:

const [kayttajat, postaukset] = await Promise.all([
  haeJson("https://jsonplaceholder.typicode.com/users"),
  haeJson("https://jsonplaceholder.typicode.com/posts")
]);

Huomaa, että Promise.all epäonnistuu kokonaan, jos yksikin pyyntö epäonnistuu. Jos haluat tulokset niiltä osin kuin ne onnistuivat, käytä Promise.allSettled-metodia, joka palauttaa jokaisesta lupauksesta tilatiedon ja arvon.

Lataustilan näyttäminen käyttäjälle

Käyttöliittymän kannalta tämä on olennainen osa asynkronisuutta: pyynnön aikana käyttäjä odottaa, eikä sivu saa näyttää siltä että se on jumissa. Lukitse painike ja kerro mitä tapahtuu.

async function nappiaKlikattu(e) {
  const nappi = e.currentTarget;
  const tila = document.getElementById("tila");
  const lista = document.getElementById("kayttajat");

  nappi.disabled = true;              // estetään tuplaklikkaus
  tila.textContent = "Haetaan…";

  try {
    const kayttajat = await haeJson("https://jsonplaceholder.typicode.com/users");

    const fragmentti = document.createDocumentFragment();
    for (const kayttaja of kayttajat) {
      const rivi = document.createElement("li");
      rivi.textContent = kayttaja.name;
      fragmentti.append(rivi);
    }
    lista.replaceChildren(fragmentti);

    tila.textContent = `${kayttajat.length} käyttäjää.`;
  }
  catch (virhe) {
    tila.textContent = `Haku epäonnistui: ${virhe.message}`;
  }
  finally {
    // finally suoritetaan aina, myös virhetilanteessa
    nappi.disabled = false;
  }
}

finally-lohko on tässä olennainen: ilman sitä painike jäisi lukituksi, jos haku epäonnistuu.

Aikakatkaisu

Fetchillä ei ole oletusaikakatkaisua. Jos palvelin ei vastaa, pyyntö voi jäädä roikkumaan pitkäksi aikaa. Aikaraja asetetaan AbortSignal.timeout()-metodilla:

try {
  const vastaus = await fetch(osoite, { signal: AbortSignal.timeout(5000) });
  const data = await vastaus.json();
}
catch (virhe) {
  if (virhe.name === "TimeoutError") {
    console.error("Palvelin ei vastannut viidessä sekunnissa");
  }
  else {
    console.error(virhe.message);
  }
}

Pyynnön voi myös perua itse AbortController-objektilla — esimerkiksi silloin kun käyttäjä kirjoittaa hakukenttään uuden merkin ja edellinen haku on vielä kesken:

let ohjain = null;

async function hae(hakusana) {
  // perutaan mahdollinen aiempi haku
  if (ohjain !== null) {
    ohjain.abort();
  }
  ohjain = new AbortController();

  const vastaus = await fetch(`${osoite}?q=${hakusana}`, { signal: ohjain.signal });
  return await vastaus.json();
}

Peruttu pyyntö hylkää lupauksen virheellä, jonka name on "AbortError".

CORS javascript-ohjelmoijan näkökulmasta

Ennemmin tai myöhemmin fetch-pyyntö epäonnistuu, konsoliin ilmestyy punainen CORS-virheilmoitus eikä mikään koodimuutos tunnu auttavan. Tässä luvussa kerrotaan, mistä on kyse ja mitä asialle voi javascript-puolella tehdä. Palvelimen asetukset kuuluvat Web-palvelinohjelmoinnin kurssille TIES4080.

Same-origin policy

Selaimen perussääntö on, että javascript-koodi saa lukea vastauksia vain samasta lähteestä kuin mistä sivu itse on ladattu. Lähde muodostuu kolmesta osasta: protokolla, isäntänimi ja portti. Kaikkien kolmen on täsmättävä.

Englanninkielinen termi on origin, ja se esiintyy sekä virheilmoituksissa että otsakkeiden nimissä (Access-Control-Allow-Origin).

Sivun osoitePyynnön kohdeSama lähde?
https://users.jyu.fi/~tommi/https://users.jyu.fi/data.jsonkyllä
https://users.jyu.fi/~tommi/http://users.jyu.fi/data.jsonei — eri protokolla
https://users.jyu.fi/~tommi/https://appro.mit.jyu.fi/data.jsonei — eri isäntä
https://users.jyu.fi/~tommi/https://users.jyu.fi:8080/data.jsonei — eri portti
file:///C:/tyo/sivu.htmlmikä tahansaei — tiedosto-osoitteen lähde on null

Viimeinen rivi on syy siihen, miksi kurssilla toistetaan, että sivu on avattava WWW-palvelimen kautta eikä kaksoisklikkaamalla tiedostoa. Tiedosto-osoitteelta tehty fetch epäonnistuu aina.

Mitä CORS tekee

CORS (Cross-Origin Resource Sharing) on tapa, jolla palvelin voi sallia poikkeuksen tähän sääntöön. Palvelin lähettää vastauksessa otsakkeen

Access-Control-Allow-Origin: *

jolloin selain päästää javascript-koodin lukemaan vastauksen. Juuri tämän takia JSONPlaceholder toimii esimerkeissä mistä tahansa: se lähettää tuon otsakkeen.

Olennaista on ymmärtää kuka päättää mitäkin:

Huomaa myös, että pyyntö lähtee ja palvelin käsittelee sen — selain vain estää vastauksen lukemisen. CORS ei siis suojaa palvelinta vaan käyttäjää: se estää sivustoa lukemasta tietoja, joihin käyttäjä on kirjautunut toisaalla.

Esitarkistuspyyntö

Yksinkertaiset pyynnöt — GET ja tavallinen lomakemuotoinen POST — lähtevät suoraan. Jos pyynnössä on esimerkiksi Content-Type: application/json, omia otsakkeita tai metodi PUT tai DELETE, selain lähettää ensin automaattisesti OPTIONS-pyynnön ja kysyy luvan.

Tämä näkyy kehittäjätyökalujen Network-välilehdellä ylimääräisenä OPTIONS-rivinä. Jos se epäonnistuu, varsinaista pyyntöä ei koskaan lähetetä. Esitarkistusta ei tarvitse itse tehdä eikä siihen voi vaikuttaa.

Miltä CORS-virhe näyttää koodissa

Tämä yllättää: CORS-virhettä ei voi erottaa verkkovirheestä. Fetch hylkää lupauksen TypeError-virheellä, jonka viesti on yleensä "Failed to fetch" tai "NetworkError". Tilakoodia ei ole luettavissa, koska koodi ei saa lukea vastauksesta mitään:

try {
  const vastaus = await fetch("https://jokumuu.example/data.json");
  console.log(vastaus.status);     // tänne ei päästä
}
catch (virhe) {
  console.error(virhe.name, virhe.message);   // TypeError Failed to fetch
}

Varsinainen selitys löytyy vain konsolista. Selain kirjoittaa sinne tarkan syyn — esimerkiksi että Access-Control-Allow-Origin-otsake puuttuu. Lue se ilmoitus; se on ainoa paikka, josta syy selviää.

Älä käytä no-cors-tilaa

Kun CORS-virhe tulee vastaan, hakukone ehdottaa asetusta mode: "no-cors". Se ei korjaa mitään. Pyyntö kyllä onnistuu, mutta vastauksesta tulee läpinäkymätön (opaque): tilakoodi on 0, otsakkeita ei näy, ja rungon lukeminen antaa tyhjän merkkijonon.

const vastaus = await fetch(osoite, { mode: "no-cors" });
console.log(vastaus.status);       // 0
console.log(await vastaus.text()); // "" - tyhjä

Virheilmoitus katoaa mutta data ei tule perille, mikä on hankalampi vikatilanne kuin selkeä virhe.

Mitä voit oikeasti tehdä

Palvelinpuolen CORS-asetuksiin ja niiden turvallisuusvaikutuksiin perehdytään kurssilla TIES4080 Web-palvelinohjelmointi.

Fetchin asetukset

Toisena parametrina annetaan asetusobjekti. Useimmiten tarvitaan vain method, headers ja body — loput ovat oletusarvoillaan järkeviä.

const uusiPostaus = {
  title: "Otsikko",
  body: "Sisältö",
  userId: 1
};

const vastaus = await fetch("https://jsonplaceholder.typicode.com/posts", {
  method: "POST",
  headers: {
    "Content-Type": "application/json"
  },
  body: JSON.stringify(uusiPostaus)     // rungon on vastattava Content-Type-otsaketta
});

if (!vastaus.ok) {
  throw new Error(`HTTP-virhe ${vastaus.status}`);
}

console.log(await vastaus.json());      // JSONPlaceholder palauttaa { ..., id: 101 }

JSONPlaceholder ei oikeasti tallenna mitään, mutta se vastaa kuten oikea rajapinta, joten POST-pyyntöä voi harjoitella turvallisesti.

Lomakedatan voi lähettää myös suoraan FormData-objektina. Silloin ei pidä asettaa Content-Type-otsaketta itse: selain asettaa sen ja tarvittavan erotinmerkinnän automaattisesti.

const vastaus = await fetch(osoite, {
  method: "POST",
  body: new FormData(lomake)
});

Muita asetuksia, joita voi joutua säätämään:

AsetusOletusMerkitys
method"GET"HTTP-metodi
headers—pyynnön otsakkeet
body—pyynnön runko; ei sallittu GET-pyynnössä
credentials"same-origin"lähetetäänkö evästeet mukana
cache"default"välimuistin käyttö
signal—pyynnön peruminen, katso Aikakatkaisu
mode"cors"älä muuta, katso no-cors

credentials: "include" lähettää evästeet myös toiseen lähteeseen, mutta se vaatii palvelimelta erikseen luvan — Access-Control-Allow-Origin: * ei riitä, vaan lähde on nimettävä täsmällisesti.

Lisätietoa

Käyttäjien kommentit

Kommentoi Lisää kommentti