This course is now available in English.Click here to change the language.

Olemme nyt viipyneet tovin keskittyen pelkkään "frontendiin", eli selainpuolen toiminnallisuuteen. Rupeamme itse toteuttamaan "backendin", eli palvelinpuolen toiminnallisuutta vasta kurssin kolmannessa osassa, mutta otamme nyt jo askeleen sinne suuntaan tutustumalla siihen, miten selaimessa suoritettava koodi kommunikoi backendin kanssa.

Käytetään nyt palvelimena sovelluskehitykseen tarkoitettua JSON Serveriä.

Tehdään projektin juurihakemistoon tiedosto db.json, jolla on seuraava sisältö:

{
  "notes": [
    {
      "id": 1,
      "content": "HTML is easy",
      "date": "2019-05-30T17:30:31.098Z",
      "important": true
    },
    {
      "id": 2,
      "content": "Browser can execute only Javascript",
      "date": "2019-05-30T18:39:34.091Z",
      "important": false
    },
    {
      "id": 3,
      "content": "GET and POST are the most important methods of HTTP protocol",
      "date": "2019-05-30T19:20:14.298Z",
      "important": true
    }
  ]
}

JSON server on mahdollista asentaa koneelle ns. globaalisti komennolla npm install -g json-server. Globaali asennus edellyttää kuitenkin pääkäyttäjän oikeuksia, eli se ei ole mahdollista laitoksen koneilla tai uusilla fuksiläppäreillä.

Globaali asennus ei kuitenkaan ole tarpeen, voimme käynnistää json-serverin komennon npx avulla:

npx json-server --port=3001 --watch db.json

Oletusarvoisesti json-server käynnistyy porttiin 3000, mutta create-react-app:illa luodut projektit varaavat portin 3000, joten joudumme nyt määrittelemään json-serverille vaihtoehtoisen portin 3001.

Mennään selaimella osoitteeseen http://localhost:3001/notes. Kuten huomaamme, json-server tarjoaa osoitteessa tiedostoon tallentamamme muistiinpanot JSON-muodossa:

fullstack content

Jos selaimesi ei osaa näyttää JSON-muotoista dataa formatoituna, asenna jokin sopiva plugin, esim. JSONView helpottamaan elämääsi.

Ideana jatkossa onkin se, että muistiinpanot talletetaan palvelimelle, eli tässä vaiheessa json-serverille. React-koodi hakee muistiinpanot palvelimelta ja renderöi ne ruudulle. Kun sovellukseen lisätään uusi muistiinpano, React-koodi lähettää sen myös palvelimelle, jotta uudet muistiinpanot jäävät pysyvästi "muistiin".

json-server tallettaa kaiken datan palvelimella sijaitsevaan tiedostoon db.json. Todellisuudessa data tullaan tallentamaan johonkin tietokantaan. json-server on kuitenkin käyttökelpoinen apuväline, joka mahdollistaa palvelinpuolen toiminnallisuuden käyttämisen kehitysvaiheessa ilman tarvetta itse ohjelmoida mitään.

Tutustumme palvelinpuolen toteuttamisen periaatteisiin tarkemmin kurssin osassa 3.

Selain suoritusympäristönä

Ensimmäisenä tehtävänämme on siis hakea React-sovellukseen jo olemassaolevat mustiinpanot osoitteesta http://localhost:3001/notes.

Osan 0 esimerkkiprojektissa nähtiin jo eräs tapa hakea Javascript-koodista palvelimella olevaa dataa. Esimerkin koodissa data haettiin XMLHttpRequest- eli XHR-olion avulla muodostetulla HTTP-pyynnöllä. Kyseessä on vuonna 1999 lanseerattu tekniikka, jota kaikki web-selaimet ovat jo pitkään tukeneet.

Nykyään XHR:ää ei kuitenkaan kannata käyttää ja selaimet tukevatkin jo laajasti fetch-metodia, joka perustuu XHR:n käyttämän tapahtumapohjaisen mallin sijaan ns. promiseihin.

Muistutuksena edellisestä osasta (oikeastaan tätä tapaa pitää lähinnä muistaa olla käyttämättä ilman painavaa syytä), XHR:llä haettiin dataa seuraavasti

const xhttp = new XMLHttpRequest()

xhttp.onreadystatechange = function() {
  if (this.readyState == 4 && this.status == 200) {
    const data = JSON.parse(this.responseText)
    // käsittele muuttujaan data sijoitettu kyselyn tulos
  }
}

xhttp.open('GET', '/data.json', true)
xhttp.send()

Heti alussa HTTP-pyyntöä vastaavalle xhttp-oliolle rekisteröidään tapahtumankäsittelijä, jota Javascript runtime kutsuu kun xhttp-olion tila muuttuu. Jos tilanmuutos tarkoittaa että pyynnön vastaus on saapunut, käsitellään data halutulla tavalla.

Huomionarvoista on se, että tapahtumankäsittelijän koodi on määritelty jo ennen kun itse pyyntö lähetetään palvelimelle. Tapahtumankäsittelijäfunktio tullaan kuitenkin suorittamaan vasta jossain myöhäisemmässä vaiheessa. Koodin suoritus ei siis etene synkronisesti "ylhäältä alas", vaan asynkronisesti, Javascript kutsuu sille rekisteröityä tapahtumankäsittelijäfunktiota jossain vaiheessa.

Esim. Java-ohjelmoinnista tuttu synkroninen tapa tehdä kyselyjä etenisi seuraavaan tapaan (huomaa että kyse ei ole oikeasti toimivasta Java-koodista):

HTTPRequest request = new HTTPRequest();

String url = "https://fullstack-exampleapp.herokuapp.com/data.json";
List<Note> notes = request.get(url);

notes.forEach(m => {
  System.out.println(m.content);
});

Javassa koodi etenee nyt rivi riviltä ja koodi pysähtyy odottamaan HTTP-pyynnön, eli komennon request.get(...) valmistumista. Komennon palauttama data, eli muistiinpanot talletetaan muuttujaan ja dataa aletaan käsittelemään halutulla tavalla.

Javascript-enginet eli suoritusympäristöt kuitenkin noudattavat asynkronista mallia, eli periaatteena on se, että kaikki IO-operaatiot (poislukien muutama poikkeus) suoritetaan ei-blokkaavana, eli operaatioiden tulosta ei jäädä odottamaan vaan koodin suoritusta jatketaan heti eteenpäin.

Siinä vaiheessa kun operaatio valmistuu tai tarkemmin sanoen jonain valmistumisen jälkeisenä ajanhetkenä, kutsuu Javascript-engine operaatiolle rekisteröityjä tapahtumankäsittelijöitä.

Nykyisellään Javascript-moottorit ovat yksisäikeisiä eli ne eivät voi suorittaa rinnakkaista koodia. Tämän takia on käytännössä pakko käyttää ei-blokkaavaa mallia IO-operaatioiden suorittamiseen, sillä muuten selain 'jäätyisi' siksi aikaa kun esim. palvelimelta haetaan dataa.

Javascript-moottoreiden yksisäikeisyydellä on myös sellainen seuraus, että jos koodin suoritus kestää erittäin pitkään, menee selain jumiin suorituksen ajaksi. Jos lisätään sovelluksen alkuun seuraava koodi:

setTimeout(() => {
  console.log('loop..')
  let i = 0
  while (i < 50000000000) {
    i++
  }
  console.log('end')
}, 5000)

Kaikki toimii 5 sekunnin ajan normaalisti. Kun setTimeout:in parametrina määritelty funktio suoritetaan, menee selaimen sivu jumiin pitkän loopin suorituksen ajaksi. Ainakaan Chromessa selaimen tabia ei pysty edes sulkemaan luupin suorituksen aikana.

Eli jotta selain säilyy responsiivisena, eli että se reagoi koko ajan riittävän nopeasti käyttäjän haluamiin toimenpiteisiin, koodin logiikan tulee olla sellainen, että yksittäinen laskenta ei saa kestää liian kauaa.

Aiheesta löytyy paljon lisämateriaalia internetistä, eräs varsin havainnollinen esitys aiheesta Philip Robertsin esitelmä What the heck is the event loop anyway?

Nykyään selaimissa on mahdollisuus suorittaa myös rinnakkaista koodia ns. web workerien avulla. Yksittäisen selainikkunan koodin ns. event loopista huolehtii kuitenkin edelleen vain yksi säie.

npm

Palaamme jälleen asiaan, eli datan hakemiseen palvelimelta.

Voisimme käyttää datan palvelimelta hakemiseen aiemmin mainittua promiseihin perustuvaa funktiota fetch. Fetch on hyvä työkalu, se on standardoitu ja kaikkien modernien selaimien (poislukien IE) tukema.

Käytetään selaimen ja palvelimen väliseen kommunikaatioon kuitenkin axios-kirjastoa, joka toimii samaan tapaan kuin fetch, mutta on hieman mukavampikäyttöinen. Hyvä syy axios:in käytölle on myös se, että pääsemme tutustumaan siihen miten ulkopuolisia kirjastoja eli npm-paketteja liitetään React-projektiin.

Nykyään lähes kaikki Javascript-projektit määritellään node "pakkausmanagerin" eli npm:n avulla. Myös create-react-app:in avulla generoidut projektit ovat npm-muotoisia projekteja. Varma tuntomerkki siitä on projektin juuressa oleva tiedosto package.json:

{
  "name": "notes",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "react": "^16.8.6",
    "react-dom": "^16.8.6",
    "react-scripts": "3.0.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": "react-app"
  },
  "browserslist": [
    ">0.2%",
    "not dead",
    "not ie <= 11",
    "not op_mini all"
  ]
}

Tässä vaiheessa meitä kiinnostaa osa dependencies, joka määrittelee mitä riippuvuuksia eli ulkoisia kirjastoja projektilla on.

Haluamme nyt käyttöömme axioksen. Voisimme määritellä kirjaston suoraan tiedostoon package.json, mutta on parempi asentaa se komentoriviltä

npm install axios --save

Huomaa, että npm-komennot tulee antaa aina projektin juurihakemistossa, eli siinä minkä sisältä tiedosto package.json_ löytyy.

Nyt axios on mukana riippuvuuksien joukossa:

{
  "dependencies": {
    "axios": "^0.19.0",    "react": "^16.8.6",
    "react-dom": "^16.8.6",
    "react-scripts": "3.0.1"
  },
  // ...
}

Sen lisäksi, että komento npm install lisäsi axiosin riippuvuuksien joukkoon, se myös latasi kirjaston koodin. Koodi löytyy muiden riippuvuuksien tapaan projektin juuren hakemistosta node_modules, mikä kuten huomata saattaa sisältääkin runsaasti kaikenlaista.

Tehdään toinenkin pieni lisäys. Asennetaan myös json-server projektin sovelluskehityksen aikaiseksi riippuvuudeksi komennolla

npm install json-server --save-dev

ja tehdään tiedoston package.json osaan scripts pieni lisäys

{
  // ... 
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject",
    "server": "json-server -p3001 db.json"  }
}

Nyt voimme käynnistää json-serverin projektin hakemistosta mukavasti ilman tarvetta parametrien määrittelylle komennolla

npm run server

Tutustumme npm-työkaluun tarkemmin kurssin kolmannessa osassa.

Huomaa, että aiemmin käynnistetty json-server tulee olla sammutettuna, muuten seuraa ongelmia

fullstack content

Virheilmoituksen punaisella oleva teksti kertoo mistä on kyse:

Cannot bind to the port 3001. Please specify another port number either through --port argument or through the json-server.json configuration file

eli sovellus ei onnistu käynnistyessään kytkemään itseään porttiin, syy tälle on se, että portti 3001 on jo aiemmin käynnistetyn json-serverin varaama.

Käytimme komentoa npm install kahteen kertaan hieman eri tavalla

npm install axios --save
npm install json-server --save-dev

Parametrissa oli siis hienoinen ero. axios tallennettiin sovelluksen ajonaikaiseksi riippuvuudeksi (--save), sillä ohjelman suoritus edellyttää kirjaston olemassaoloa. json-server taas asennettiin sovelluskehityksen aikaiseksi riippuvuudeksi (--save-dev), sillä ohjelma itse ei varsinaisesti kirjastoa tarvitse, se on ainoastaan apuna sovelluksehityksen aikana. Erilaisista riipuvuuksista lisää kurssin seuraavassa osassa.

Axios ja promiset

Olemme nyt valmiina käyttämään axiosia. Jatkossa oletetaan että json-server on käynnissä portissa 3001. Lisäksi varsinainen React-sovellus tulee käynnistää erikseen, erilliseen komentorivi-ikkunaan komennolla:

npm start

Kirjaston voi ottaa käyttöön samaan tapaan kuin esim. React otetaan käyttöön, eli sopivalla import-lauseella.

Lisätään seuraava tiedostoon index.js

import axios from 'axios'

const promise = axios.get('http://localhost:3001/notes')
console.log(promise)

const promise2 = axios.get('http://localhost:3001/foobar')
console.log(promise2)

Konsoliin tulostuu seuraavaa

fullstack content

Axiosin metodi get palauttaa promisen.

Mozillan dokumentaatio sanoo promisesta seuraavaa:

A Promise is an object representing the eventual completion or failure of an asynchronous operation.

Promise siis edustaa asynkronista operaatiota. Promise voi olla kolmessa eri tilassa:

  • aluksi promise on pending, eli promisea vastaava asynkroninen operaatio ei ole vielä tapahtunut
  • jos operaatio päättyy onnistuneesti, menee promise tilaan fulfilled, josta joskus käytetään nimitystä resolved
  • kolmas mahdollinen tila on rejected, joka edustaa epäonnistunutta operaatiota

Esimerkkimme ensimmäinen promise on fulfilled, eli vastaa onnistunutta axios.get('http://localhost:3001/notes') pyyntöä. Promiseista toinen taas on rejected, syy selviää konsolista, eli yritettiin tehdä HTTP GET -pyyntöä osoitteeseen, jota ei ole olemassa.

Jos ja kun haluamme tietoon promisea vastaavan operaation tuloksen, tulee promiselle rekisteröidä tapahtumankuuntelija. Tämä tapahtuu metodilla then:

const promise = axios.get('http://localhost:3001/notes')

promise.then(response => {
  console.log(response)
})

Konsoliin tulostuu seuraavaa

fullstack content

Javascriptin suoritusympäristö kutsuu then-metodin avulla rekisteröityä takaisinkutsufunktiota antaen sille parametriksi olion result, joka sisältää kaiken oleellisen HTTP GET -pyynnön vastaukseen liittyvän, eli palautetun datan, statuskoodin ja headerit.

Promise-olioa ei ole yleensä tarvetta tallettaa muuttujaan, ja onkin tapana ketjuttaa metodin then kutsu suoraan axiosin metodin kutsun perään:

axios.get('http://localhost:3001/notes').then(response => {
  const notes = response.data
  console.log(notes)
})

Takaisinkutsufunktio ottaa nyt vastauksen sisällä olevan datan muuttujaan ja tulostaa muistiinpanot konsoliin.

Luettavampi tapa formatoida ketjutettuja metodikutsuja on sijoittaa jokainen kutsu omalle rivilleen:

axios
  .get('http://localhost:3001/notes')
  .then(response => {
    const notes = response.data
    console.log(notes)
  })

näin jo nopea, ruudun vasempaan laitaan kohdistunut vilkaisu kertoo mistä on kyse.

Palvelimen palauttama data on pelkkää tekstiä, käytännössä yksi iso merkkijono. Axios-kirjasto osaa kuitenkin parsia datan Javascript-taulukoksi, sillä palvelin on kertonut headerin content-type avulla että datan muoto on application/json; charset=utf-8 (ks. edellinen kuva).

Voimme vihdoin siirtyä käyttämään sovelluksessamme palvelimelta haettavaa dataa.

Tehdään se aluksi "huonosti", eli lisätään sovellusta vastaavan komponentin App renderöinti takaisinkutsufunktion sisälle muuttamalla index.js seuraavaan muotoon:

import ReactDOM from 'react-dom'
import React from 'react'
import App from './App'

import axios from 'axios'

axios.get('http://localhost:3001/notes').then(response => {
  const notes = response.data
  ReactDOM.render(
    <App notes={notes} />,
    document.getElementById('root')
  )
})

Joissain tilanteissa tämäkin tapa voisi olla ok, mutta se on hieman ongelmallinen ja päätetäänkin siirtää datan hakeminen komponenttiin App.

Ei ole kuitenkaan ihan selvää, mihin kohtaan komponentin koodia komento axios.get olisi hyvä sijoittaa.

Effect-hookit

Olemme jo käyttäneet Reactin version 16.8.0 mukanaan tuomia state hookeja tuomaan funktioina määriteltyihin React-komponentteihin tilan. Versio 16.8.0 tarjoaa kokonaan uutena ominaisuutena myös effect hookit, dokumentaation sanoin

The Effect Hook lets you perform side effects in function components. Data fetching, setting up a subscription, and manually changing the DOM in React components are all examples of side effects.

Eli effect hookit ovat juuri oikea tapa hakea dataa palvelimelta.

Poistetaan nyt datan hakeminen tiedostosta index.js. Komponentille App ei ole enää tarvetta välittää dataa propseina. Eli index.js pelkistyy seuraavaan muotoon

ReactDOM.render(<App />, document.getElementById('root'))

Komponentti App muuttuu seuraavasti:

import React, { useState, useEffect } from 'react'import axios from 'axios'import Note from './components/Note'

const App = () => {
  const [notes, setNotes] = useState([]) 
  const [newNote, setNewNote] = useState('')
  const [showAll, setShowAll] = useState(true)

  useEffect(() => {    console.log('effect')    axios      .get('http://localhost:3001/notes')      .then(response => {        console.log('promise fulfilled')        setNotes(response.data)      })  }, [])  console.log('render', notes.length, 'notes')
  // ...
}

Koodiin on myös lisätty muutama aputulostus, jotka auttavat hahmottamaan miten suoritus etenee.

Konsoliin tulostuu


render 0 notes
effect
promise fulfilled
render 3 notes

Ensin siis suoritetaan komponentin määrittelevan funktion runko ja renderöidään komponentti ensimmäistä kertaa. Tässä vaiheessa tulostuu render 0 notes eli dataa ei ole vielä haettu palvelimelta.

Efekti, eli funktio

() => {
  console.log('effect')
  axios
    .get('http://localhost:3001/notes')
    .then(response => {
      console.log('promise fulfilled')
      setNotes(response.data)
    })
}

suoritetaan heti renderöinnin jälkeen. Funktion suoritus saa aikaan sen, että konsoliin tulostuu effect ja että komento axios.get aloittaa datan hakemisen palvelimelta sekä rekisteröi operaatiolle tapahtumankäsittelijäksi funktion

response => {
  console.log('promise fulfilled')
  setNotes(response.data)
})

Siinä vaiheessa kun data saapuu palvelimelta, Javascriptin runtime kutsuu rekisteröityä tapahtumankäsittelijäfunktiota, joka tulostaa konsoliin promise fulfilled sekä tallettaa tilaan palvelimen palauttamat muistiinpanot funktiolla setNotes(response.data).

Kuten aina, tilan päivittävän funktion kutsu aiheuttaa komponentin uudelleen renderöitymisen. Tämän seurauksena konsoliin tulostuu render 3 notes ja palvelimelta haetut muistiinpanot renderöityvät ruudulle.

Tarkastellaan vielä efektihookin määrittelyä kokonaisuudessaan

useEffect(() => {
  console.log('effect')
  axios
    .get('http://localhost:3001/notes').then(response => {
      console.log('promise fulfilled')
      setNotes(response.data)
    })
}, [])

Kirjotetaan koodi hieman toisella tavalla.

const hook = () => {
  console.log('effect')
  axios
    .get('http://localhost:3001/notes')
    .then(response => {
      console.log('promise fulfilled')
      setNotes(response.data)
    })
}

useEffect(hook, [])

Nyt huomaamme selvemmin, että funktiolle useEffect annetaan kaksi parametria. Näistä ensimmäinen on funktio, eli itse efekti. Dokumentaation mukaan

By default, effects run after every completed render, but you can choose to fire it only when certain values have changed.

Eli oletusarvoisesti efekti suoritetaan aina sen jälkeen, kun komponentti renderöidään. Meidän tapauksessamme emme kuitenkaan halua suorittaa efektin kuin ensimmäisen renderöinnin yhteydessä.

Funktion useEffect toista parametria käytetään tarkentamaan sitä miten usein efekti suoritetaan. Jos toisena parametrina on tyhjä taulukko [], suoritetaan efekti ainoastaan komponentin ensimmäisen renderöinnin jälkeen.

Efektihookien avulla on mahdollisuus tehdä paljon muutakin kuin hakea dataa palvelimelta, tämä riittää kuitenkin meille tässä vaiheessa.

Mieti vielä tarkasti äsken läpikäytyä tapahtumasarjaa, eli mitä kaikkea koodista suoritetaan, missä järjetyksessä ja kuinka monta kertaa. Tapahtumien järjestyksen ymmärtäminen on erittäin tärkeää!

Huomaa, että olisimme voineet kirjoittaa efektifunktion koodin myös seuraavasti:

useEffect(() => {
  console.log('effect')

  const eventHandler = response => {
    console.log('promise fulfilled')
    setNotes(response.data)
  }

  const promise = axios.get('http://localhost:3001/notes')
  promise.then(eventHandler)
}, [])

Muuttujaan eventHandler on sijoitettu viite tapahtumankäsittelijäfunktioon. Axiosin metodin get palauttama promise on talletettu muuttujaan promise. Takaisinkutsun rekisteröinti tapahtuu antamalla promisen then-metodin parametrina muuttuja eventHandler, joka viittaa käsittelijäfunktioon. Useimmiten funktioiden ja promisejen sijoittaminen muuttujiin ei ole tarpeen ja ylempänä käyttämämme kompaktimpi esitystapa riittää:

useEffect(() => {
  console.log('effect')
  axios
    .get('http://localhost:3001/notes')
    .then(response => {
      console.log('promise fulfilled')
      setNotes(response.data)
    })
}, [])

Sovelluksessa on tällä hetkellä vielä se ongelma, että jos lisäämme uusia muisiinpanoja, ne eivät tallennu palvelimelle asti. Eli kun uudelleenlataamme sovelluksen, kaikki lisäykset katoavat. Korjaus asiaan tulee pian.

Sovelluksen tämän hetkinen koodi on kokonaisuudessaan githubissa, branchissa part2-4.

Sovelluskehityksen suoritusympäristö

Sovelluksemme kokonaisuuden konfiguraatiosta on pikkuhiljaa muodostunut melko monimutkainen. Käydään vielä läpi mitä tapahtuu missäkin. Seuraava diagrammi kuvaa asetelmaa

fullstack content

React-sovelluksen muodostavaa Javascript-koodia siis suoritetaan selaimessa. Selain hakee Javascriptin React dev serveriltä, joka on se ohjelma, mikä käynnistyy kun suoritetaan komento npm start. Dev-serveri muokkaa sovelluksen Javascriptin selainta varten sopivaan muotoon, se mm. yhdistelee eri tiedostoissa olevan Javascript-koodin yhdeksi tiedostoksi. Puhumme enemmän dev-serveristä kurssin osassa 7.

JSON-muodossa olevan datan selaimessa pyörivä React-sovellus siis hakee koneella portissa 3001 käynnissä olevalta json-serveriltä, joka taas saa JSON-datan tiedostosta db.json.

Kaikki sovelluksen osat ovat näin sovelluskehitysvaiheessa ohjelmoijan koneella eli localhostissa. Tilanne muuttuu sitten kun sovellus viedään internettiin. Teemme näin osassa 3.