Asynkroninen JavaScript

Attention

Asynkroninen JavaScript on laaja aihe, joka voi tuntua aluksi hankalalta; samalla se on myös erittäin tärkeä ottaa haltuun: ilman asynkronista JavaScriptiä meillä ei olisi nykyaikaisia interaktiivisia verkkosivuja tai verkkosovelluksia.

Tutustu siis huolella asynkronisen JavaScriptin käsitteisiin ja katso viitteet ja lisämateriaalit jokaisen osion lopussa.

Loput harjoitukset ja kurssitehtävät hyödyntävät asynkronista JavaScriptia.

JavaScript on yksisäikeinen mutta asynkroninen ja blokkautumaton (non-blocking). Miten se saavutetaan?

Kun tehtävää käsitellään pääsäikeessä (main thread), mitään muuta ei voi tehdä samaan aikaan. Tarkastellaanpa blokkautuvaa koodia:

const btn = document.querySelector('button');
btn.addEventListener('click', () => {
  // Funktion suoritus pysähtyy tähän kohtaan,
  // kunnes käyttäjä reagoi ilmoitukseen
  alert('You clicked me!');

  // Koodi jatkuu tästä, kun käyttäjä on reagoinut ilmoitukseen
  let pElem = document.createElement('p');
  pElem.textContent = 'This is a newly-added paragraph.';
  document.body.appendChild(pElem);
});

Mitä tapahtuu, kun painiketta klikataan?

  1. Luodaan ilmoitus (alert). Mitään muuta ei voi tehdä ennen kuin käyttäjä kuittaa ilmoituksen.

  2. Sivulle lisätään uusi kappale

Kun suoritat tehtävän, jonka suorittaminen kestää pitkään, kannattaa käyttää asykronisia operaatioita, jolloin pääsäiettä ei kuormiteta tarpeettomasti. Näin käyttäjä voi silti olla vuorovaikutuksessa sivun kanssa samalla, kun tehtävää käsitellään taustalla.

Referenssit ja lisämateriaali:

Promise

Promise kehitettiin helpottamaan asynkronisen koodin kirjoittamista. Promise-objekti edustaa asynkronisen toiminnan lopullista arvoa tai epäonnistumista.

Promise on arvo, jota ei välttämättä tunneta, kun se luodaan. Promisella on kolme eri tilaa:

  • Pending

    • Promisea ei ole vielä ratkaistu

  • Fulfilled

    • Promise onnistui

    • Arvo on käyttökelpoinen

  • Rejected

    • Toiminto epäonnistui

Promise-konstruktori ottaa yhden argumentin: takaisinkutsufunktion kahdella parametrilla, resolve ja reject, jotka ovat molemmat takaisinkutsuja.

const myFirstPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("Success!");
  }, 1000);
});

myFirstPromise.then((successMessage) => {
  console.log("Yay! " + successMessage);
});

Aikaavievät toiminnot on syytä tehdä Promisen sisällä. Promise-konstruktoria kutsutaan takaisinkutsufunktiolla, joka on kaksi vakioargumenttia: resolve ja reject. Kun kaikki tarvittava on tehty onnistuneesti kutsutaan resolve. Jos jokin menee pieleen, kutsutaan puolestaan reject. Resolve jatkaa then-haarassa ja välittää parametrin arvoksi saman, millä resolvea kutsuttiin. Jos taas jokin menee pieleen, niin kutsutaan reject. Samalla tavalla sen parametri siirtyy catch-haaraan. Useita aikaavieviä operaatioita voidaan myös ketjuttaa, jolloin then-haaroja voi olla useita.

Yllä olevassa esimerkissä luomme uuden Promisen, joka ratkeaa 1000 ms:n odottamisen jälkeen. Kun suoritus aloitetaan, saamme ensin Promisen pending tilassa. Yhden sekunnin kuluttua Promise ratkeaa (resolves) ja siirtyy toteutuneeseen tilaan (fulfilled), jonka jälkeen printataan “Yay! Success”-viesti.


Katsotaan seuraavaksi aiempaa esimerkkiä tietojen hakemisesta API:sta.

const baseUrl = "https://jsonplaceholder.typicode.com";
const postsUrl = `${baseUrl}/posts`;
const usersUrl = `${baseUrl}/users`;
const userId = 1;

document.getElementById("fetchBtn").addEventListener("click", function() {
  fetch(`${usersUrl}/${userId}`)
    .then((response) => {
      return response.json();
    })
    .then((user) => {
      document.getElementById("userData").innerText = JSON.stringify(user);
      return user;
    })
    .then((user) => {
      fetch(`${postsUrl}?userId=${user.id}`)
        .then((response) => {
          return response.json();
        })
        .then((posts) => {
          document.getElementById("postsData").innerText = JSON.stringify(posts);
        })
    })
    .catch((error) => {
      console.error(error.message);
    })
})

Useat kirjastot käyttävät Promiseja async-toimintoihinsa, esim. jQuery. JavaScript Fetch (fetch) on saatavana lähes kaikista ympäristöistä, joten se on hyvä valinta tietojen hakemiseen.

Referenssit ja lisämateriaali:

Harjoitus

Tässä harjoituksessa tehdään funktion myFirstPromise, joka ottaa shouldFail:n parametrina. Funktio palauttaa Promise:n.

  • Jos shouldFail on true, Promise epäonnistuu (reject) paluuarvolla Fail.

  • Jos shouldFail on false, Promise onnistuu (resolve) paluuarvolla Success.

A+ esittää tässä kohdassa tehtävän palautuslomakkeen.

Async/Await

Koska “Promise”-käsitteenä on hieman hankala haltuunotettava, on kehitetty syntaktista sokeria piilottamaan Promiset ja asynkronisen JavaScriptin monimutkaisuus. Tässä osiossa tutkimme async ja await -käsitteitä.

Funktio määritellään asynkroniseksi käyttämällä async-avainsanaa:

async function example() {}

const example = async () => {};

Async-funktiot toimivat eri järjestyksessä kuin tavalliset toiminnot event-loopin kautta. Async-funktion palautusarvo on implisiittinen Promise.

async-funktion sisällä voidaan käyttää await-avainsanaa. await keskeyttää funktion suorittamisen ja odottaa Promise:n ratkeamista.

Seuraavassa esimerkissä suoritetaan toiminto, joka ratkeaa yhden sekunnin kuluttua async- ja await-avainsanojen avulla:

function resolveAfter1Second() {
  return new Promise(resolve => {
    setTimeout(function() {
      resolve("success!");
    }, 1000);
  });
}

const runAsync = async () => {
  const response = await resolveAfter1Second();
  console.log("The operation was " + response);
};

runAsync(); // Toiminto onnistui!

Promiset määritellään kuten aiemmin, mutta niiden toteutumista jäädään odottamaan await-avainsanalla. awaitin käyttö edellyttää, että odottavan funktion määrittelyyn lisätään async. Huom! await ei estä koko pääsäiettä, vaan vain kyseisen async-funktion jtkumisen, muiden tapahtumien käsittely normaalisti.

Katsotaan seuraavaksi edellistä API-esimerkkiä. Voiko sitä parantaa käyttämällä async/await-toimintoja?

const baseUrl = "https://jsonplaceholder.typicode.com";
const postsUrl = `${baseUrl}/posts`;
const usersUrl = `${baseUrl}/users`;
const userId = 1;

const fetchAsync = async url => {
  const response = await fetch(url);
  const data = await response.json();
  return data;
}

document.getElementById("fetchBtn").addEventListener("click", async () => {
  const userData = await fetchAsync(`${usersUrl}/${userId}`);
  document.getElementById("userData").innerText = JSON.stringify(userData);

  const userPosts = await fetchAsync(`${postsUrl}?userId=${userData.id}`);
  document.getElementById("postsData").innerText = JSON.stringify(userPosts);
});

Näyttää paljon siistimmältä!

Katsotaanpa ensin fetchAsync-toimintoa. Muistatko edellisistä esimerkeistämme, että fetch palautti Promisen ja että voimme odottaa (await), että. promise saadaan ratkaistua (resolve)? Nyt voimme hakea tietoja mistä tahansa URL-osoitteesta kolmella rivillä uudelleenkäytettävää koodia.

Koska määritämme callback-funktio fetchBtn:n asynkroniseksi, voimme käyttää siinä await-toimintoa.

Async/await tekee koodista helpommin luettavan, nopeamman kirjoittaa ja intuitiivisemman testata.

Referenssit ja lisämateriaali:

Promise.all

Entä jos meidän pitäisi hakea tietoja käyttäjiltä 1, 2 ja 3? Mitä jos käyttäjiä olisi sata?

Promise.all sopii erinomaisesti tietojen hakemiseen rinnakkain. Se ottaa argumenttina iterable (joukon Promiseja) ja palauttaa Promisen. Tämä palautettu Promise täyttyy vasta, kun kaikki alkuperäiset Promiset ovat ratkenneet.

Promise.all() epäonnistuu (rejects), jos jokin joukon Promiseista hylätään.

Muutetaan esimerkkiämme hieman. Sen sijaan, että noudettaisiin käyttäjä ja sitten tietoja kyseisen käyttäjän postauksista, voisimme hakea kaksi käyttäjää ja näyttää heidän tietonsa. Nämä pyynnöt voidaan tehdä samanaikaisesti Promise.all:n kanssa.

const baseUrl = "https://jsonplaceholder.typicode.com";
const usersUrl = `${baseUrl}/users`;
const usersArray = [1, 2];

const fetchAsync = async url => {
  const response = await fetch(url);
  const data = await response.json();
  return data;
}

document.getElementById("fetchBtn").addEventListener("click", async () => {
  // Huomaa, että map-toiminto ottaa vastaan asynkronisen funktion,
  // joka aina palauttaa Promiseja, joten usersData on joukko Promiseja
  const usersData = usersArray.map(async user => fetchAsync(`${usersUrl}/${user}`));
  Promise.all(usersData).then(completed => {
    completed.map((user, index) => {
      document.getElementById(`userData${index + 1}`).innerText = JSON.stringify(user);
    });
  });
})

usersData on joukko Promiseja. Kun välitämme usersData:n argumenttina Promise.all:lle saamme yhden Promisen. Kun tuo yksittäinen Promise ratkeaa siirrymme then-lauseeseen, jossa meillä on tiedot käytettävissä.

Referenssit ja lisämateriaali:

Loppusanat ja lisää tietoa

Tämä luku esitteli monia varsin monimutkaisia käsitteitä lyhyessä ajassa.

JavaScriptin asynkroninen luonne hämmentää monia uusia webbidevaajia, jotka kysyvät samat kysymykset StackOverflowssa yhä uudelleen ja uudelleen. Harjoittele tarpeeksi JavaScriptin asynkronista käyttöä heti alussa ja lue lisää siitä, niin koodauskokemuksesi on huomattavasti parempi.

Katso viitteet ja linkit kunkin yllä olevan osion lopussa saadaksesi lisätietoja.