Javascript ja DOM

Harjoitellaan Javascriptin ja DOM-rajapinnan perusteita.

Mallivastaus (mallivastaus.zip)

Alkuvalmistelut

Hello World

Tallenna käyttöösi valmis dokumenttipohja johonkin väliaikaishakemistoon.

Otsikoiden numerointi

Muutellaan elementtien sisältöä.

Mitä ollaan tekemässä

Sivun otsikot ovat aluksi tällaisia:

<h2>Tietoja minusta</h2>
<h2>Opiskeluni</h2>
<h2>Harrastukseni</h2>

Ohjelman suorituksen jälkeen niiden pitäisi olla tällaisia:

<h2>1. Tietoja minusta</h2>
<h2>2. Opiskeluni</h2>
<h2>3. Harrastukseni</h2>

Rakenne ei siis muutu lainkaan — vain otsikoiden tekstisisältö muuttuu. Elementtejä ei luoda eikä poisteta, joten koko tehtävä hoituu textContent-ominaisuudella.

  1. Lisää uusi funktio numeroi ja kutsu sitä window-objektin load-tapahtuman käsittelijäfunktiossa.
  2. Hae numeroi-funktiossa kaikki h2-elementit sopivan nimiseen muuttujaan. Tämä onnistuu document.getElementsByTagName-metodilla.

    getElementsByTagName palauttaa aina HTMLCollection-tyyppisen kokoelman riippumatta siitä, montako alkiota löytyi. Kokoelma on elävä eli DOM-puuhun tehdyt muutokset heijastuvat siihen välittömästi.

    HTMLCollection ei ole taulukko, vaikka siinä on length ja sitä voi indeksoida hakasulkeilla. Siinä ei ole forEach- eikä map-metodia. Jos tarvitset taulukon metodeja, muunna kokoelma taulukoksi: Array.from(kokoelma) tai [...kokoelma].

    document.getElementsByTagName etsii aina koko DOM-puusta, elementti.getElementsByTagName vain kyseisen elementin jälkeläisistä.

  3. Haetaan h2-elementtien tekstit. Yritä tulostaa kaikkien h2-elementtien tekstit konsoliin.
    • Voit käydä kokoelman läpi for..of-silmukalla.
    • Yksittäisen h2-elementin saat kokoelmasta item-metodilla tai []-operaattorilla aivan kuten taulukosta.
    • Kokeile, miten h2-elementin textContent-ominaisuus toimii. Sillä saat suoraan elementin koko tekstisisällön eikä tarvitse välittää erillisistä tekstisolmuista.
      Tekstisolmu?
      • Kunkin elementin sisällä oleva teksti muodostaa oikeasti oman Text-tyypin olionsa. Tekstisolmuja voi olla myös useita.
      • Tekstisolmun saat tässä tapauksessa h2-elementin firstChild-ominaisuudesta.
      • Varsinaisen tekstin saa nodeValue-ominaisuudesta. Huomaa, että nodeValue antaa tekstisisällön vain tekstisolmuilta.
      • Yleensä riittää textContent-ominaisuuden käyttäminen eikä erillisistä tekstisolmuista tarvitse välittää.
  4. textContent-ominaisuuteen voi myös asettaa arvoja. Lisää sivulle näkyviin kunkin nykyisen otsikon eteen otsikkonumerointi alkaen numerosta 1. Tarvitset tähän erillisen laskurimuuttujan tai entries()-metodin.
  5. Miksi et voi kutsua numeroi()-funktiota muualta kuin load-tapahtuman käsittelijästä? Kokeile laittaa kutsu käsittelijän ulkopuolelle. Mitä tapahtuu — tai ei tapahdu? Miksi?

Elementtien lisäys

Lisätään elementtejä sivulle dynaamisesti DOM-rajapinnan avulla.

Linkit tekstiksi linkkilistaan

Linkkilista, jossa jokaisen linkin perään on lisätty sen osoite tekstinä

Mitä ollaan tekemässä

Linkkilista on aluksi tällainen:

<ul id="linkit">
  <li><a href="https://appro.mit.jyu.fi/">Appro</a></li>
  <li><a href="https://www.jyu.fi/">Jyväskylän yliopisto</a></li>
</ul>

Ohjelman suorituksen jälkeen jokaisen linkin perään on lisätty sen osoite tekstinä:

<ul id="linkit">
  <li><a href="https://appro.mit.jyu.fi/">Appro</a> https://appro.mit.jyu.fi/</li>
  <li><a href="https://www.jyu.fi/">Jyväskylän yliopisto</a> https://www.jyu.fi/</li>
</ul>

Huomaa, että teksti tulee li-elementin sisään a-elementin jälkeen — ei linkin sisään. Jos teksti menisi a-elementin sisään, siitä tulisi osa klikattavaa linkkiä.

Näin nuo kaksi rakennetta näyttävät selaimessa. Kokeile klikata linkkejä: jälkimmäisessä osoite on tavallista tekstiä eikä osa linkkiä.

Ennen:

Jälkeen:

Sama html-koodi selaimessa esitettynä.
  1. Luo uusi funktio linkit ja kutsu sitä windowin load-käsittelijäfunktiossa, jonka loit jo aiemmin.
  2. Merkitse id-attribuutilla dokumentin lopussa oleva ul-elementti.
  3. Hae linkit-funktiossa tuo ul-elementti johonkin muuttujaan.
  4. Pyydä kaikkia tämän ul-elementin alla olevia a-elementtejä getElementsByTagName-metodilla. elementti.getElementsByTagName() etsii aina kyseisen elementin jälkeläisistä.

    Toinen vaihtoehto on elementti.querySelectorAll(), joka hakee elementtejä css-valitsimen perusteella (vrt. viikkotehtävässä 1 tehdyt valitsimet). Huomaa ero: querySelectorAll palauttaa staattisen NodeListin, getElementsByTagName elävän HTMLCollectionin.

    Kokeile hakea oikeat a-elementit ensin getElementsByTagName-metodilla ja yritä sen jälkeen tehdä sama querySelectorAll-metodilla.

  5. Tee silmukka, hae kunkin a-elementin osoite href-ominaisuudesta ja tulosta se konsoliin. Sama onnistuu myös getAttribute-metodilla, joka palauttaa HTML-elementin attribuutin arvon.

    Seuraavassa on demonstroitu, miten käytetään javascript-objektin ominaisuuksia ja toisaalta html-elementin attribuutteja. Useimmilla html-attribuuteilla on olemassa vastaava javascript-ominaisuus, mutta ei kaikilla. Kannattaa aina tarkistaa MDN:n sivulta, mitä ominaisuuksia kullakin DOM-objektilla on tarjolla. Omia ominaisuuksia saa myös vapaasti keksiä.

    document.forms[1][1];
    <input type="text" value="">
    document.forms[1][1].type;
    "text"
    document.forms[1][1].value;
    ""
    document.forms[1][1].getAttribute("text");   // olematon attribuutti on null
    null
    document.forms[1][1].getAttribute("type");
    "text"
    document.forms[1][1].getAttribute("value");
    ""
    document.forms[1][1].type = "number";
    "number"
    document.forms[1][1].type;
    "number"
    document.forms[1][1].getAttribute("type");
    "number"
    document.forms[1][1].setAttribute("type","email");
    undefined
    document.forms[1][1].getAttribute("type");
    "email"
    document.forms[1][1].type;
    "email"
    document.forms[1][1].value = 1;
    1
    document.forms[1][1].value;                  // value on AINA merkkijono
    "1"
    document.forms[1][1].getAttribute("value");  // hupsista, tämä ei muutu:
    ""                                           // attribuutti on kentän OLETUSARVO
    document.forms[1][1].setAttribute("value","-");
    undefined
    document.forms[1][1].value;                  // arvo on edelleen se, mikä
    "1"                                          // value-ominaisuuteen asetettiin
    document.forms[1][1].getAttribute("value");  // oletusarvo on muuttunut
    "-"

    Huomaa, kuinka input-elementin value toimii poikkeavasti: attribuutti on alkuarvo, ominaisuus on nykyinen arvo. Kts. Datan liittäminen elementtiin ja JavaScript: What's the difference between HTML attribute and DOM property?

  6. Luo osoitteesta uusi tekstisolmu createTextNode-metodilla li-elementin sisään (ei a-elementin). Miksi et voi tässä käyttää textContent-ominaisuutta? Voit kokeilla toteuttaa tämän sen avulla, niin huomaat miksi se ei onnistu.
  7. Lisää luomasi tekstisolmu kyseisen a-elementin sisältävän li-elementin loppuun. Tarvitset parentNode-ominaisuutta ja append- tai appendChild-metodia. Sinun pitää myös keksiä, miten saat a-elementin kautta käsiisi li-elementin.
  8. Kokeile, toimiiko skripti. Ulkoasua voi parantaa lisäämällä tekstin alkuun vielä yhden välilyönnin. Koodin uudelleenkäytettävyyttä voi parantaa siten, että funktion parametriksi laitetaan id, tämä id haetaan dokumentista ja sen jälkeläisinä olevat linkit "tekstitetään".

Tapahtumat

Tapahtumankäsittelijän lisääminen

  1. Luo johonkin kohtaan dokumenttia painike button-elementillä. Anna painikkeelle jokin id-arvo ja näkyvä teksti Hello world. Älä laita painiketta form-elementin sisään.
  2. Luo uusi funktio varoitus. Tapahtumankäsittelijänä toimivat funktiot ottavat aina yhden parametrin, jonka nimi on yleensä e.

    function varoitus(e) {
      console.log("Hello world!");
    }

    Toimiva malli. Avaa konsoli, niin näet saman tulosteen myös siellä. Vasen painike reagoi tavalliseen klikkaukseen, oikea vain tuplaklikkaukseen. Tuloste kertoo myös tapahtuman tyypin e.type-ominaisuudesta.
  3. Lisää load-tapahtuman käsittelijään koodi, joka hakee painikkeen muuttujaan. Tämä onnistuu document-olion getElementById-metodilla. Huomaa, että metodi palauttaa null, jos elementtiä ei löydy — ei esimerkiksi tyhjää kokoelmaa. Juuri siitä seuraa aloittelijan yleisin virheilmoitus "Cannot read properties of null".
  4. Aseta painikkeelle click-tapahtuman käsittelijäksi funktio varoitus addEventListener-metodilla. Huomaa, ettei funktion nimen perään tule sulkeita: annamme osoittimen funktioon, emme kutsu sitä.
    painike.addEventListener("click", varoitus);
  5. Testaa painikkeen toimintaa.
  6. Muuta ohjelman toimintaa siten, että Hello world ilmestyy vain tuplaklikkauksesta. Kts. dblclick.
  7. Muuta tuplaklikkauksen käsittely takaisin tavalliseksi click-tapahtumaksi. Siirrä painikkeesi lomakkeen (form-elementti) sisään. Miten ohjelmasi toimii nyt?
    <form action="">
    
    </form>
    • Lomakkeen sisällä oleva painike toimii lomakkeen lähetyspainikkeena. Jos painikkeen tyypiksi määrää button, painike ei lähetä lomaketta:
      <button type="button">Tavallinen painike</button>
      Lähetyspainike voi siis olla jompikumpi seuraavista:
      <button>Lähetä</button>
      <input type="submit" value="Lähetä" />
      Huomaa, ettei lähetyspainiketta kääritä label-elementtiin: teksti tulee button-elementin sisältä tai value-attribuutista.
    • Oletuksena lomakkeen sisältö lähetetään www-palvelimelle osoitteeseen, joka on ilmoitettu form-elementin action-attribuutissa. Tällä kurssilla ei käsitellä www-palvelimeen liittyviä ohjelmia; käytännössä selain vain lataa sivusi uudelleen.
    • Jos lomake halutaan käsitellä javascriptillä, täytyy estää sen lähettäminen www-palvelimelle. Tämä tehdään preventDefault-metodilla lomakkeen submit-tapahtuman käsittelijässä. Huomaa, että submit on lomakkeen tapahtuma, ei painikkeen:

      document.forms[0].addEventListener("submit", kasittele);
      
      function kasittele(e) {
        e.preventDefault();
        // lomakkeen käsittely tähän
      }

      Samalla metodilla voidaan estää minkä tahansa tapahtuman oletustoiminta. Useimmiten estetään lomakkeen submit-tapahtuma tai linkin click-tapahtuma.

Avattava menu, jossa pääkohtien edessä on plus- ja miinuskuvake

Tehdään menu, jonka alakohtia voidaan avata ja sulkea. Tässä tarvitaan tapahtumankäsittelyä. Menu tehdään kahteen kertaan kahdella eri tavalla, jotta ero tulee näkyviin.

Kokeile ensin alla olevaa mallia. Näin menun on tarkoitus toimia: kolmiosta avautuu ja sulkeutuu alakohtien lista.

Artikkelit
Harkat
Linkit

Huomaa, ettei tuossa mallissa ole riviäkään javascriptiä. Se on tehty pelkillä details- ja summary-elementeillä, jotka osaavat avautua ja sulkeutua itsestään. Ne toimivat myös näppäimistöltä, ja ruudunlukija kertoo tilan automaattisesti.

Oikeassa projektissa tekisit menun juuri noin. Tässä ohjauksessa se kuitenkin rakennetaan javascriptillä, koska tarkoitus on harjoitella DOM-rajapintaa ja tapahtumankäsittelyä — ei siksi, että se olisi paras tapa tehdä avattava lista. Tämä kannattaa muistaa: ennen kuin kirjoittaa javascriptiä, on hyvä tarkistaa, osaako HTML jo tehdä saman.

  1. Luo dokumentin alkuun kaksitasoinen sisäkkäinen lista, jossa on pääkohdat Artikkelit, Harkat ja Linkit ja näiden alla pari listakohtaa, joiden sisällä on linkit.

    Laita jokaisen pääkohdan eteen painike, jonka sisällä on minus.jpg-kuva merkitsemään avattua menukohtaa:

    <ul id="navi">
      <li>
        <button type="button" aria-expanded="true">
          <img src="minus.jpg" alt="" />
        </button>
        Artikkelit
        <ul>
          <li><a href="">IT ja tietoliikenne</a></li>
          <li><a href="">Opiskelijaruokaa</a></li>
        </ul>
      </li>
      ...
    </ul>

    Miksi painike eikä pelkkä kuva? Klikattava kuva on käyttöliittymänä rikki: sitä ei voi käyttää näppäimistöllä, se ei ole osa sarkainkiertoa eikä ruudunlukija kerro sen olevan mitään muuta kuin kuva. button saa kaiken tämän ilmaiseksi. type="button" estää sitä lähettämästä lomaketta, jos menu joskus päätyy lomakkeen sisään.

    aria-expanded-attribuutti kertoo ruudunlukijalle, onko kohta auki vai kiinni. Koska tila kerrotaan sillä, kuvan alt-teksti jätetään tyhjäksi: kuva on pelkkää koristetta, eikä sitä tarvitse lukea ääneen.

  2. Merkitse ulompi lista id-attribuutilla, esimerkiksi id="navi".
  3. Luo nanonano.js-tiedostoon uusi funktio muuta_nakyvyys, joka ottaa parametrikseen painikkeen.
  4. Hae load-tapahtuman käsittelijässä tuo lista muuttujaan document.getElementById-metodilla.
  5. Pyydä kaikkia listan alla olevia button-elementtejä getElementsByTagName- tai querySelectorAll-metodilla.
  6. Käy kokoelma silmukalla läpi ja lisää kunkin painikkeen click-tapahtuman käsittelijä:
    for (const nappi of napit) {
      nappi.addEventListener("click", (e) => muuta_nakyvyys(e.currentTarget));
    }

    currentTarget on elementti, jonka käsittelijää suoritetaan — tässä siis painike. target voisi olla painikkeen sisällä oleva kuva, koska klikkaus osuu siihen.

  7. Selvitä muuta_nakyvyys-funktiossa, mikä alalista painikkeeseen liittyy. Painikkeen vanhempi on li-elementti, jonka sisältä alalista löytyy. Kokeile debuggerilla tai tulosta rakenne konsoliin.
  8. Vaihda kuvan src-attribuutin arvoksi plus.jpg, jos ennen oli minus.jpg, ja muutoin toisin päin. Kuvan saat painikkeen sisältä esimerkiksi nappi.querySelector("img")-kutsulla.
  9. Piilota alalista samalla kun asetat kuvaksi plus.jpg, ja näytä se muutoin.

    • Lisää CSS-tiedostoon luokka:
      .piilota {
        display: none;
      }
    • Luokkia käsitellään classList-ominaisuudella. toggle palauttaa true, jos luokka on kutsun jälkeen paikallaan — eli jos lista on nyt piilossa:
      const piilossa = alalista.classList.toggle("piilota");
    • Vanhoissa esimerkeissä näkee className-ominaisuuden käyttöä ja luokan poistamista removeAttribute("class")-metodilla. Ne toimivat, mutta className on yksi merkkijono, jota joutuu pilkkomaan käsin, ja koko attribuutin poistaminen tuhoaa elementin muut luokat.
  10. Päivitä lopuksi aria-expanded-attribuutti vastaamaan tilaa, jotta ruudunlukijan käyttäjä tietää mitä tapahtui:
    nappi.setAttribute("aria-expanded", piilossa ? "false" : "true");
  11. Testaa hiirellä ja näppäimistöllä. Siirry sarkaimella painikkeeseen ja paina Enter tai välilyöntiä — menun pitäisi toimia myös näin.
  12. Tutki Inspectorilla, millainen puu sisäkkäisestä listasta muodostuu.

    Selaimen Inspector-näkymä, jossa sisäkkäinen lista on avattuna puuna

  13. Poista vielä navigointipalkin uloimman listan alkioiden pallukat. Suoraviivaisin tapa on CSS-ominaisuus list-style-type, joka javascriptissä kirjoitetaan camelCase-muodossa:
    elementti.style.listStyleType = "none";

    Myös elementti.style.display = "block" poistaa pallukan, mutta sivuvaikutuksena: luettelomerkki syntyy arvosta display: list-item, ja kun display vaihdetaan, merkkikin katoaa. Tulos on sama, mutta list-style-type kertoo lukijalle mitä oikeasti tarkoitettiin.

    Vinkki: käy silmukalla läpi listan lapsielementit (children). Se sisältää vain elementit, joten childNodes-kokoelman tekstisolmuja ei tarvitse suodattaa pois.

    Huomaa, että tämä kannattaisi oikeasti tehdä suoraan tyylitiedostossa. Tässä sitä harjoitellaan javascriptillä, jotta style-ominaisuuden käyttö tulee tutuksi.

  14. Voit vielä siirtää koko listan sivun oikeaan laitaan.

Edellä lisäsit käsittelijän silmukassa jokaiseen painikkeeseen. Tehdään sama uudelleen toisella tavalla. Tapahtumat kuplivat eli etenevät kohteesta ylöspäin kohti dokumentin juurta, joten käsittelijän voi liittää yhteen yhteiseen esivanhempaan. Tätä kutsutaan delegoinniksi. Kts. Tapahtuman eteneminen DOM-puussa ja Delegointi.

  1. Kommentoi pois silmukka, jossa lisäsit käsittelijän jokaiselle painikkeelle.
  2. Lisää yksi ainoa käsittelijä uloimmalle ul-elementille:
    navi.addEventListener("click", menua_klikattu);
  3. Kirjoita menua_klikattu-funktio. Nyt e.currentTarget on aina koko lista, joten se ei kerro mitä klikattiin — e.target kertoo. Klikkaus voi osua mihin tahansa listan sisällä, myös linkkiin tai tekstiin, joten tarkista onko kohde painike:

    function menua_klikattu(e) {
      const nappi = e.target.closest("button");
    
      if (nappi === null) {
        return;        // klikkaus ei osunut painikkeeseen, ei tehdä mitään
      }
    
      muuta_nakyvyys(nappi);
    }

    closest etsii ylöspäin: se palauttaa lähimmän esivanhemman tai elementin itsensä, joka vastaa css-valitsinta — tai null, jos osumaa ei ole. Tämä on tässä välttämätöntä, koska klikkaus osuu useimmiten painikkeen sisällä olevaan kuvaan eikä painikkeeseen itseensä.

  4. Testaa, että menu toimii edelleen — myös näppäimistöltä.
  5. Kokeile, mitä delegoinnilla voitti. Lisää sivulle painike, joka lisää menuun uuden kohdan ajon aikana:

    <p><button type="button" id="lisaa_menukohta">Lisää menuun uusi kohta</button></p>

    Kirjoita painikkeelle käsittelijä, joka rakentaa uuden menukohdan DOM-rajapinnalla. Uusi elementti luodaan document.createElement-metodilla, ja se liitetään puuhun append-metodilla. Luotu elementti ei näy sivulla ennen kuin se liitetään:

    function lisaa_menukohta() {
      // createElement luo elementin, mutta se on aluksi irrallaan dokumentista
      const kohta = document.createElement("li");
    
      const nappi = document.createElement("button");
      nappi.type = "button";
      nappi.setAttribute("aria-expanded", "true");
    
      const kuva = document.createElement("img");
      kuva.src = "minus.jpg";
      kuva.alt = "";
    
      // append ottaa vastaan useita argumentteja kerralla ja hyväksyy myös
      // merkkijonoja, jotka lisätään TEKSTINÄ - ei html-koodina
      nappi.append(kuva);
      kohta.append(nappi, "Uusi kohta");
    
      const alalista = document.createElement("ul");
      const rivi = document.createElement("li");
      rivi.textContent = "Alakohta";
      alalista.append(rivi);
      kohta.append(alalista);
    
      // vasta tämä rivi saa kohdan näkyviin
      document.getElementById("navi").append(kohta);
    }

    Huomaa, ettei missään käytetä innerHTML-ominaisuutta. Rakenne kootaan elementti kerrallaan, jolloin DOM-rajapinta pitää huolen siitä, että lopputulos on ehjä. Tämä on sama tekniikka, jota tarvitset kohta yhteenlaskupelissä.

  6. Paina painiketta ja klikkaa sitten uuden kohdan kuvaketta. Se toimii, vaikka sille ei koskaan lisätty käsittelijää — käsittelijä on listassa, ja tapahtuma kuplii sinne.

    Kokeile samaa versiolla 1: palauta silmukka käyttöön, kommentoi delegoitu käsittelijä pois ja paina painiketta uudestaan. Nyt uusi kohta ei reagoi mitenkään, koska silmukka ehti käydä jo ennen sen luomista.

  7. Kokeile lopuksi, mitä tapahtuu, jos etenemisen estää. Lisää muuta_nakyvyys-funktion alkuun väliaikaisesti

    e.stopPropagation();

    versiossa 1 ja katso konsolista, ettei listalle liitetty käsittelijä enää suoriudu. Poista rivi kokeilun jälkeen. Tämä on syy siihen, miksi stopPropagation-metodia pitää käyttää harkiten: se rikkoo muiden koodinpätkien delegoinnin, eikä vikaa ole helppo löytää.

Kumpi versio on parempi? Delegoitu. Käsittelijöitä tarvitaan yksi sadan sijaan, ja käsittely toimii myös elementeille, jotka lisätään sivulle vasta myöhemmin. Silmukkaversio on silti hyvä tuntea, koska sitä näkee paljon.

Yhteenlaskupeli

Rivejä, joissa kaksinumeroisia lukuja ja tekstikenttä niiden summaa varten

Tehdään Nanonanon sivuille päässälaskun harjoitteluun pieni yhteenlaskutehtäviä generoiva peli.

Mitä ollaan tekemässä

Kokeile alla olevaa mallia. Se on perusversio: kymmenen arvottua laskua ja Tarkista-painike. Laajennukset lisäävät tähän vielä vapaan rivimäärän ja rivien poistamisen.

Tämä malli rakennetaan javascriptillä. Jos tämä teksti jää näkyviin, skriptiä ei ladattu.

Toimiva malli. Kokeile myös jättää kenttä tyhjäksi ja kirjoittaa siihen desimaaliluku — kumpikaan ei kelpaa oikeaksi vastaukseksi.

Yksi laskurivi on rakenteeltaan tällainen:

<p>07 + 42 = <input type="number" aria-label="07 plus 42" /></p>

Arvotut luvut eivät näy html-koodissa muuten kuin tekstinä. Ne talletetaan input-elementin omiin ominaisuuksiin, jolloin ne säilyvät oikeina numeroina eikä niitä tarvitse jäsentää takaisin tekstistä.

  1. Lisää h2-otsikko ja sen alle div-lohko, jonka id-attribuutin arvo on laskut. Lisää myös painike Tarkista, jolla on oma id-arvonsa. Huom. id ei saa alkaa numerolla.
  2. Tee funktio luo_lasku, joka palauttaa p-elementin. Se sisältää arvotun laskutehtävän tekstinä ja tekstilaatikon vastaukselle. Tuotettu HTML-koodi on muotoa:
    <p>LUKU1 + LUKU2 = <input type="number" /></p>
    • Elementit luodaan document.createElement-metodilla ja liitetään toisiinsa append-metodilla. Luotu elementti ei näy sivulla ennen kuin se liitetään dokumenttipuuhun:

      const p = document.createElement("p");
      const kentta = document.createElement("input");
      
      kentta.type = "number";
      p.textContent = `${luku1} + ${luku2} = `;
      p.append(kentta);
      
      return p;

      Älä käytä innerHTML-ominaisuutta. Se on tällä kurssilla kielletty, ja syyt on kerrottu Hello World -luvun alussa.

    • Arvo tekstien LUKU1 ja LUKU2 tilalle jotkin luvut väliltä 0–99. Tämä onnistuu Math.random-funktiolla.
    • Tallenna arpomasi luvut luomasi input-elementin yhteyteen käyttämällä omia ominaisuuksia, esimerkiksi input.luku1 = LUKU1;. Näin luvut pysyvät oikeina numeroina eikä niitä tarvitse lukea takaisin tekstistä.
    • Huom. size-attribuutti ei ole sallittu numerokentälle. Kentän leveys säädetään tyylitiedostossa, esimerkiksi #laskut input { width: 4em; }.
  3. Tee funktio luo_laskut, joka luo kymmenen laskua silmukassa ja lisää ne laskut-div-lohkon sisälle. Kutsu luo_lasku-funktiota silmukassa. Lisää luo_laskut-funktiokutsu load-tapahtuman käsittelijään.
  4. Tee CSS-tiedostoon uusi luokka .virhe, joka muuttaa elementin taustavärin punaiseksi ja tekstin värin mustaksi.
  5. Tee funktio tarkista_laskut. Lisää se load-tapahtuman käsittelijässä Tarkista-painikkeen click-tapahtuman käsittelijäksi.
  6. Käy tarkista_laskut-funktiossa kaikki laskurivit läpi silmukassa ja tarkista, ovatko kaikki laskut oikein.
    • Summaa luvut yhteen ja vertaa, onko tulos sama kuin tekstilaatikossa.
    • Vertailua varten tekstikenttään syötetty arvo on muutettava numeroksi. Kentän value-ominaisuus on aina merkkijono, myös numerokentässä. Muunnokseen on kolme tapaa:

      Number.parseInt(kentta.value, 10);   // lukee alusta niin pitkälle kuin pystyy
      Number(kentta.value);                // koko merkkijonon on kelvattava
      kentta.valueAsNumber;                // vain numerokentillä (type="number")

      Toteuta tavalla, jossa esimerkiksi "100a" ei kelpaa vaikka summa olisi 100. Kokeile kaikkia kolmea ja katso, mitä ne palauttavat syötteillä "100", "100a" ja "".

      Number.parseInt hyväksyy "100a"-syötteen ja palauttaa 100, joten se yksinään ei riitä. Number hylkää sen ja palauttaa NaN — mutta varo: Number("") on 0 eikä NaN. valueAsNumber on numerokentälle siistein: se palauttaa NaN, jos kenttä on tyhjä tai sisältö ei kelpaa luvuksi. Tarkista tulos Number.isNaN-metodilla.

    • Jos laskettu luku ei ole oikein, lisää tekstilaatikolle luokka virhe:
      kentta.classList.add("virhe");
    • Jos rivi on kunnossa, poista luokka kentta.classList.remove("virhe")-kutsulla. Vielä lyhyemmin molemmat tapaukset hoituvat yhdellä rivillä: kentta.classList.toggle("virhe", !oikein).
    • Jos kaikki rivit ovat ok, ilmoita painikkeen alapuolella sopivalla tekstillä pelin läpipääsystä.
  7. Testaa pelin toimintaa.

Laajennus 1: vapaa määrä laskurivejä

Perusversio luo aina kymmenen laskua. Tehdään pelistä dynaaminen: käyttäjä saa itse päättää, montako riviä lisätään ja milloin.

  1. Lisää sivulle numerokenttä rivien määrälle ja painike, jolla rivit lisätään. Anna kentälle järkevät rajat ja oletusarvo:
    <p>
      <label>Lisättäviä rivejä
        <input type="number" id="maara" value="5" min="1" max="50" />
      </label>
      <button type="button" id="lisaa">Lisää rivejä</button>
    </p>
  2. Muuta luo_laskut-funktiota niin, että se ottaa parametrikseen lisättävien rivien määrän. Funktio ei enää tyhjennä vanhoja rivejä vaan lisää uudet olemassa olevien perään.
  3. Lue lisättävä määrä kentästä. value on aina merkkijono, myös numerokentässä, joten muunna se luvuksi. Muista myös, ettei käyttäjän syötteeseen voi luottaa:

    const kentta = document.getElementById("maara");
    const maara = kentta.valueAsNumber;
    
    if (!Number.isInteger(maara) || maara < 1) {
      kentta.reportValidity();     // näyttää selaimen oman virheilmoituksen
      return;
    }

    valueAsNumber on NaN, jos kenttä on tyhjä tai sisältö ei kelpaa luvuksi. Sama onnistuu myös Number.parseInt- tai Number-funktiolla, mutta varo: Number("") on nolla eikä NaN.

  4. Liitä lisaa-painikkeeseen click-käsittelijä, joka kutsuu luo_laskut-funktiota kentästä luetulla määrällä.
  5. Säilytä "Uusi peli" -painike, mutta muuta sen toimintaa: se tyhjentää kaikki rivit ja aloittaa alusta. Tyhjentämiseen sopii replaceChildren() ilman argumentteja.
  6. Jos lisättäviä rivejä on paljon, kannattaa koota ne ensin irralliseen DocumentFragment-säiliöön ja liittää dokumenttiin kerralla. Silloin selain laskee sivun asettelun uudelleen kerran eikä viisikymmentä kertaa:

    const fragmentti = document.createDocumentFragment();
    for (let i = 0; i < maara; i++) {
      fragmentti.append(luo_lasku());
    }
    laskut.append(fragmentti);
  7. Testaa. Huomaa, ettei tarkista_laskut-funktioon tarvitse koskea lainkaan: se käy läpi kaikki laskut-lohkon kentät riippumatta siitä, montako niitä on.

Laajennus 2: laskurivin poistaminen

Lisätään jokaisen rivin viereen painike, jolla rivin voi poistaa.

  1. Muuta luo_lasku-funktiota niin, että se lisää riville myös poistopainikkeen:

    const poista = document.createElement("button");
    poista.type = "button";
    poista.className = "poista";
    poista.textContent = "×";
    poista.setAttribute("aria-label", "Poista tämä rivi");
    p.append(poista);

    Huomaa kolme asiaa. Käytä button-elementtiä äläkä pelkkää ×-merkkiä tekstinä: nappi toimii myös näppäimistöltä ja ruudunlukija kertoo sen olevan painike. Anna type="button", muuten painike lähettäisi lomakkeen, jos rivit joskus siirretään lomakkeen sisään. Ja koska pelkkä × ei kerro ruudunlukijalle mitään, kirjoita aria-label.

    Jos kirjoitat ×-merkin suoraan HTML-koodiin, muista ettei &times; kelpaa XHTML-dokumentissa: XML tuntee vain viisi nimettyä merkkiviittausta. Käytä numeerista muotoa &#215; tai kirjoita merkki sellaisenaan UTF-8-tiedostoon.

  2. Käytä delegointia. Älä lisää käsittelijää jokaiseen poistopainikkeeseen erikseen — rivejä syntyy jatkuvasti lisää, eikä uusi rivi saisi käsittelijää lainkaan. Liitä yksi käsittelijä laskut-lohkolle:

    const laskut = document.getElementById("laskut");
    laskut.addEventListener("click", rivia_klikattu);
    
    function rivia_klikattu(e) {
      const nappi = e.target.closest("button.poista");
    
      if (nappi === null) {
        return;          // klikkaus ei osunut poistopainikkeeseen
      }
    
      // poistetaan koko rivi eli se p-elementti, jonka sisällä painike on
      nappi.closest("p").remove();
    }

    Tässä closest-metodia käytetään kahdesti eri suuntiin ajatellen: ensin selvitetään osuiko klikkaus poistopainikkeeseen, sitten haetaan se rivi, johon painike kuuluu. remove() poistaa elementin itsensä — vanha tapa olisi ollut rivi.parentNode.removeChild(rivi).

  3. Testaa. Lisää rivejä, poista muutama väliltä ja tarkista laskut. Kaiken pitäisi toimia ilman että tarkista_laskut-funktiota muutetaan.
  4. Mieti reunatapaus: mitä tapahtuu, jos poistat kaikki rivit ja painat sitten Tarkista? Virheitä ei ole yhtään, joten peli onnittelee sinua läpipääsystä. Korjaa tämä.

  5. Jos haluat viimeistellä, tyhjennä tulosteksti aina kun rivejä lisätään tai poistetaan — muuten edellisen tarkistuksen tulos jää näkyviin, vaikka rivit ovat muuttuneet.

Datan hakeminen palvelimelta

Tähän asti kaikki sivun data on ollut kovakoodattuna joko html-koodissa tai javascript-tiedostossa. Oikeassa sovelluksessa data haetaan palvelimelta. Tässä tehtävässä kokeillaan sitä fetch-rajapinnalla.

Esimerkkidatana käytetään JSONPlaceholder-palvelua, joka tarjoaa testidataa ja sallii pyynnöt mistä tahansa lähteestä. Osoite https://jsonplaceholder.typicode.com/users palauttaa kymmenen käyttäjää JSON-muodossa:

[
  { "id": 1, "name": "Leanne Graham", "email": "Sincere@april.biz", ... },
  { "id": 2, "name": "Ervin Howell",  "email": "Shanna@melissa.tv", ... },
  ...
]

Mitä ollaan tekemässä

Tämä malli rakennetaan javascriptillä. Jos tämä teksti jää näkyviin, skriptiä ei ladattu.

Toimiva malli, joka hakee datan oikeasti verkosta. Painike lukittuu haun ajaksi. Jos olet ilman verkkoyhteyttä, näet virheilmoituksen — sekin on osa tehtävää.

Tehtävä

  1. Lisää sivulle painike, tilarivi ja tyhjä lista:
    <p><button type="button" id="hae">Hae käyttäjät</button></p>
    <p id="haku-tila"></p>
    <ul id="kayttajat"></ul>
  2. Tee nanonano.js-tiedostoon uusi funktio hae_kayttajat. Huomaa async-sana: ilman sitä await ei ole käytettävissä.

    async function hae_kayttajat() {
      const vastaus = await fetch("https://jsonplaceholder.typicode.com/users");
      const kayttajat = await vastaus.json();
      console.log(kayttajat);
    }

    Liitä funktio painikkeen click-tapahtumaan ja tarkista konsolista, että data tulee perille.

  3. Lisää tilatarkistus. Fetch ei hylkää lupausta HTTP-virheestä: vastaus 404 tai 500 näyttää koodille onnistuneelta. Tilanne on tarkistettava itse:

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

    Kokeile vaihtaa osoitteeksi .../userz ja katso, mitä tapahtuu ilman tarkistusta ja sen kanssa.

  4. Rakenna jokaisesta käyttäjästä li-elementti ja lisää ne listaan. Käytä createElement- ja append-metodeja kuten yhteenlaskupelissä, ja kokoa rivit DocumentFragment-säiliöön ennen liittämistä. Tyhjennä lista replaceChildren()-metodilla, jottei sisältö kahdennu kun painiketta painetaan uudestaan.
  5. Näytä käyttäjälle mitä tapahtuu. Verkkopyyntö kestää, ja sivu ei saa näyttää jumittuneelta. Lukitse painike haun ajaksi ja kirjoita tilariville teksti:

    nappi.disabled = true;
    tila.textContent = "Haetaan…";
  6. Käsittele virheet. Kääri haku try-lohkoon ja näytä virheilmoitus tilarivillä — ei pelkästään konsolissa, jonne käyttäjä ei katso. Avaa painike takaisin finally-lohkossa, jotta se avautuu myös virhetilanteessa:

    try {
      // haku ja listan rakentaminen
    }
    catch (virhe) {
      tila.textContent = `Haku epäonnistui: ${virhe.message}`;
    }
    finally {
      nappi.disabled = false;
    }

    Testaa: katkaise verkkoyhteys tai kirjoita osoitteeseen kirjoitusvirhe ja varmista, että painike avautuu ja virheilmoitus näkyy sivulla.

  7. Kokeile lopuksi, missä järjestyksessä asiat tapahtuvat. Lisää console.log-kutsu funktion kutsun jälkeen ja katso, tulostuuko se ennen vai jälkeen datan.

Laajennus: käyttäjän postaukset

Jos aikaa jäi, tee listasta klikattava. Klikkaamalla nimeä haetaan kyseisen käyttäjän kirjoitukset osoitteesta https://jsonplaceholder.typicode.com/posts?userId=1 ja näytetään ne nimen alla.

  1. Tallenna käyttäjän tunnus riville omana ominaisuutena, kuten teit yhteenlaskupelissä: rivi.kayttajaId = kayttaja.id;. Näin tunnus säilyy numerona eikä sitä tarvitse lukea takaisin tekstistä.
  2. Käytä delegointia. Rivit syntyvät vasta haun jälkeen, joten yksittäisiin riveihin ei kannata lisätä käsittelijöitä. Liitä yksi käsittelijä listalle ja selvitä klikattu rivi closest-metodilla — sama kuvio kuin menussa ja laskurivien poistossa.
  3. Hae postaukset samalla tavalla kuin käyttäjät ja lisää ne rivin sisään omaksi alalistakseen.
  4. Mieti, mitä pitäisi tapahtua jos samaa nimeä klikataan kahdesti. Yksinkertaisin ratkaisu on piilottaa jo haettu lista classList-metodilla eikä hakea dataa uudestaan.

XML-muotoinen data

Kaikki data ei tule JSON-muodossa. XML on yhä yleinen etenkin vanhemmissa rajapinnoissa ja RSS-syötteissä, ja sen käsittely on hyvä osata. Tässä pienessä tehtävässä haetaan Wikipedian hakutulokset XML-muodossa.

Wikipedian rajapinta on vapaasti käytettävissä ja sallii pyynnöt mistä tahansa lähteestä, kun osoitteeseen lisätään parametri origin=*. Ilman sitä selain estäisi vastauksen lukemisen — kokeile myös ilman, niin näet CORS-virheen konsolissa.

Tämä malli rakennetaan javascriptillä. Jos tämä teksti jää näkyviin, skriptiä ei ladattu.

Toimiva malli. Hakusana lähetetään Wikipedian rajapinnalle, joka vastaa XML-muodossa.
  1. Katso ensin, millaista dataa saat. Avaa seuraava osoite selaimessa ja tutki rakennetta ennen kuin kirjoitat riviäkään koodia:

    https://fi.wikipedia.org/w/api.php?action=query&list=search&srsearch=javascript&srlimit=5&format=xml&origin=*

    Vastaus on jotakin tällaista. Huomaa, että hakutuloksen elementin nimi on p — se on MediaWikin oma nimi eikä liity HTML:n kappale-elementtiin, ja kiinnostava tieto on attribuuteissa eikä tekstisisällössä:

    <api>
      <query>
        <search>
          <p ns="0" title="JavaScript" size="24601" wordcount="3020" />
          <p ns="0" title="ECMAScript" size="5312" wordcount="620" />
        </search>
      </query>
    </api>
  2. Lisää sivulle tekstikenttä hakusanalle, painike ja tyhjä lista tuloksille. Muista label-elementti kentälle.
  3. Kokoa osoitteen kyselyosa URLSearchParams-objektilla äläkä liimaamalla merkkijonoja yhteen. Se hoitaa erikoismerkkien koodauksen puolestasi — muuten hakusana "C++" tai ääkköset rikkoisivat osoitteen:

    const parametrit = new URLSearchParams({
      action: "query",
      list: "search",
      srsearch: hakusana,
      srlimit: "5",
      format: "xml",
      origin: "*"
    });
    
    const osoite = `https://fi.wikipedia.org/w/api.php?${parametrit}`;
  4. Hae data fetch-funktiolla ja tarkista vastaus.ok kuten edellisessä tehtävässä. XML-datalle ei ole omaa metodia, joten runko luetaan tekstinä text()-metodilla. Sekin palauttaa lupauksen, joten tarvitaan await:

    const vastaus = await fetch(osoite);
    if (!vastaus.ok) {
      throw new Error(`HTTP-virhe ${vastaus.status}`);
    }
    const teksti = await vastaus.text();

    Huomaa, että fetch palauttaa Response-olion eikä vastauksen sisältöä. Jos jäsentimelle annetaan vastaus-olio teksti-merkkijonon sijaan, olio muuttuu merkkijonoksi "[object Response]". Se ei ole XML:ää, joten jäsennys epäonnistuu, vaikka rajapinta palautti kelvollista dataa.

  5. Jäsennä teksti DOMParser-luokalla. Tuloksena on tavallinen Document, jota käsitellään samoilla DOM-metodeilla kuin www-sivua:

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

    Toisen parametrin on oltava "application/xml". Arvolla "text/html" muotovirheet korjailtaisiin hiljaisesti.

  6. Tarkista jäsennyksen onnistuminen. Tämä yllättää: DOMParser ei heitä poikkeusta virheellisestä XML:stä, vaan palauttaa dokumentin, jonka sisällä on parsererror-elementti. Tarkistus on tehtävä itse:

    const virhe = xml.querySelector("parsererror");
    if (virhe !== null) {
      throw new Error(`Virheellinen XML: ${virhe.textContent}`);
    }
  7. Käy tulokset läpi valitsimella "search p" ja lue otsikko getAttribute("title")-metodilla. Rakenna jokaisesta li-elementti ja lisää ne listaan.
  8. Muista lataustila ja virheenkäsittely samalla tavalla kuin edellisessä tehtävässä: painike lukkoon haun ajaksi, virheilmoitus näkyviin sivulle ja painike auki finally-lohkossa.
  9. Kokeile lopuksi kahta asiaa ja katso mitä konsolissa lukee:

    • Poista osoitteesta origin=*. Pyyntö epäonnistuu, ja fetch hylkää lupauksen TypeError-virheellä — tilakoodia ei näy, koska koodi ei saa lukea vastauksesta mitään. Varsinainen syy löytyy vain konsolin CORS-virheilmoituksesta.
    • Vaihda format=xml muotoon format=json mutta jätä jäsennys ennalleen. Mitä parsererror-tarkistus nyt tekee?

Vertaa lopuksi kahta tapaa: JSON-datalla riitti yksi json()-kutsu, XML vaati tekstin lukemisen, erillisen jäsentimen ja oman virhetarkistuksen. Tämä on yksi syy siihen, miksi uudet rajapinnat tarjoavat dataa JSON-muodossa.

Lopuksi: tarkista sivun rakenne

Ohjauksen alussa käytiin läpi, miksi html-rakenteen on pysyttävä ehjänä ja miksi kurssilla kirjoitetaan polyglot-dokumentteja. Nyt on aika tarkistaa, että javascript-koodisi on pitänyt lupauksensa.

Tämä on tärkeää juuri nyt, koska tavallinen validointi näkee vain staattisen lähdekoodin. Kaikki tässä ohjauksessa tuotettu — otsikoiden numerot, linkkien perään lisätyt osoitteet, laskurivit ja poistopainikkeet — syntyy vasta ajon aikana. Validaattori ei näe niistä mitään, ellei sille erikseen näytetä.

  1. Aja sovellus siihen tilanteeseen, jonka haluat tarkistaa: lisää laskurivejä, poista muutama, avaa ja sulje menun kohtia ja hae käyttäjät palvelimelta.

  2. Paina Ctrl+Alt+V. Dokumenttipohjaan on linkitetty validaattoriskripti, joka lähettää sivun nykyisen rakenteen W3C:n validaattorille. Tulokset ilmestyvät konsoliin.

  3. Katso konsoliin myös htmlinspectorin huomiot. Pohja seuraa DOM-puuhun tehtyjä muutoksia ja tarkistaa rakenteen automaattisesti aina kun sivu muuttuu, joten virheet näkyvät heti kun ne syntyvät.

  4. Voit tehdä saman myös käsin, jolloin näet tarkalleen mitä validaattorille lähetetään. Avaa Inspector, klikkaa html-elementtiä hiiren oikealla ja valitse Copy → Outer HTML. Liitä tulos validaattorin tekstikenttään. Sama onnistuu konsolista:

    copy(document.documentElement.outerHTML);

    copy() on konsolin oma apufunktio, ei osa javascript-kieltä.

  5. Kokeile rikkoa rakenne tahallasi ja katso, huomaako validointi sen. Lisää esimerkiksi luo_lasku-funktioon rivi, joka liittää input-elementin sisään toisen elementin, tai luo elementti väärällä nimellä. Korjaa virhe kokeilun jälkeen.

  6. Tarkista lopuksi vielä itse index.xhtml-tiedosto avaamalla se Firefoxilla. Koska pääte on .xhtml, selain jäsentää sen XML-jäsentimellä ja pysähtyy heti ensimmäiseen muotovirheeseen. Tämä ei kerro javascriptin tuottamasta rakenteesta mitään, mutta se varmistaa, että kirjoittamasi html on kunnossa.

Lisätietoa: Työkalut ja Dynaamisesti tuotetun sivun tarkistaminen.

Jos aikaa jäi, kokeile vielä toteuttaa uuden pelin arpominen ja vastaamiseen kuluneen ajan laskeminen pelin uudelleenaloituksesta hyväksyttyyn tarkistukseen. Ajan mittaamiseen käytetään Date.now()- tai performance.now()-funktiota, ei new Date()-oliota.

Mallivastaus (mallivastaus.zip)

Käyttäjien kommentit

Kommentoi Lisää kommentti