Document Object Model

W tym rozdziale przejdziemy już do bardziej praktycznych rzeczy, czyli odskoczymy od konsoli debugera, na rzecz manipulowania elementami na stronie.

Do odzwierciedlenia struktury elementów na stronie JavaScript korzysta z DOM czyli Document Object Model.
Jest to model, interfejs, który za pomocą metod i właściwości odzwierciedla dokument HTML.

Naciskamy F12 i zależnie od przeglądarki w zakładce HTML/Elements widzimy drzewo naszego dokumentu HTML składające się z różnych elementów.

Dzięki interfejsowi DOM każdy taki element dokumentu jest reprezentowany przez odpowiedni obiekt w JavaScript, który ma swoje właściwości i metody. Dodatkowo taki interfejs udostępnia nam masę odpowiednich metod i właściwości, które pozwalają nam na takim dokumencie działać.

Odwoływanie się do elementów

Aby działać na elementach strony, musimy je wcześniej pobrać. Do odwoływania się do jakiegoś elementu skorzystamy z jednej z kilku metod:

Zanim zaczniemy...

O eventach jeszcze się nie uczyliśmy i ich nie używaliśmy, ale w tym wypadku zrobimy wyjątek.
Na samym początku kursu wspomniałem, że najbezpieczniej jest wstawiać skrypty na końcu strony. Dzięki temu pierwsza zostaje wczytana treść strony, a następnie nakładana jest na nią JavaScript-owa interakcja. Drugą bardzo ważną rolą umieszczania skryptów na końcu strony jest to, że przeglądarka czytając HTML od góry do dołu wczyta elementy strony przed samym skryptem, dzięki czemu JavaScript będzie mógł na nich odwoływać. Jeżeli nasz skrypt zostałby wczytany przed takimi elementami, wtedy odwołanie do nich wyrzuciło by błąd.
Zabezpieczenie to nie jest jednak pewne, ponieważ nie możemy zagwarantować, czy osoba (np. inny programista z zespołu) używająca nasz skrypty nie wstawi go do np. head strony.
Tutaj przychodzi nam z pomocą event DOMContentLoaded, który gwarantuje nam, że skrypt zacznie swoje działanie wtedy, gdy całe drzewo DOM zostanie już wczytane. W praktyce wszystkie skrypty operujące na elementach DOM powinny korzystać z tego eventu. W przykładach pomijam ten zapis.

Ogólna konstrukcja użycia tego eventu ma postać:


document.addEventListener("DOMContentLoaded", function() {

    ...tutaj pobieramy elementy...

});

Jeżeli zagłębisz się w temat, prędzej czy później odnajdziesz w internecie skrypty, które zamiast z powyższego eventu korzystają z window.onload (lub jego odpowiednika window.addEventListener('load', ...)).
Jest to błąd. Event ten jest odpalany po całkowitym wczytaniu strony - także grafiki. Zazwyczaj wczytanie samego dokumentu DOM trwa kilkadziesiąc milisekund, natomiast czekanie na wgranie grafiki czasami może trwać baaardzo długo. Może to powodować zauważalne "dziury" na stronie.

Pobieranie elementu za pomocą getElementById

Metoda getElementById(id) pobiera element o danym ID.


<button type="button" id="btn">
    Kliknij mnie
</button>

document.addEventListener("DOMContentLoaded", function() {

    const btn = document.getElementById('btn');
    console.log(btn.innerText); //Kliknij mnie

});
Pobraliśmy element za pomocą selektora, a następnie podstawiliśmy go do zmiennej. Pobierając jakiś element ze strony, JavaScript musi przeszukiwać za każdym razem całe drzewo DOM. W naszych prostych skryptach raczej nie zauważymy różnicy, ale ogólnie takie wyszukiwania nie są optymalne. Jeżeli wyszukany element podstawimy pod zmienną przeszukiwanie drzewa już nie będzie konieczne, bo zmienna wskazuje dokładne miejsce gdzie ten element się znajduje. Zyskujemy więc na wydajności.
Ogólnie jeżeli w kodzie używasz danego elementu tylko w jednej linijce, nie musisz podstawiać go do zmiennej. Jeżeli jednak element ten pojawia się w 2 i więcej linijkach kodu - warto podstawić go pod zmienną.

Pobieranie elementów za pomocą getElementsByTagName

Metoda getElementsByTagName(tag) pobiera kolekcję elementów o danym znaczniku:


<table id="tabelka">
    <tr>
        <td>1</td>2<td class="czerwone">3</td>
    </tr>
    <tr>
        <td>4</td>5<td class="czerwone">6</td>
    </tr>
</table>

document.addEventListener("DOMContentLoaded", function() {
    const tab = document.getElementById('tabelka');

    const td = tab.getElementsByTagName('td'); //pobieramy wszystkie td z tabeli
    console.log(td.length); //wypisuje sobie ilość elementów w kolekcji

    for (let i=0; i<td.length; i++) { //pętla po wszystkich td
        td[i].style.backgroundColor = 'red'; //ustawiamy tło komórek na czerwone
    }
});

Zauważ, żę metodę getElementsByTagName wywołaliśmy nie dla dokumentu, ale dla już wcześniej pobranego elementu tab. Wszystkie z omawianych metod możemy wywoływać dla dokumentu, ale też właśnie dla pobranych wcześniej elementów. Dzięki temu szukanie elementów wykonujemy nie dla całego dokumentu, a tylko w danym elemencie.

Pobieranie elementów za pomocą getElementsByClassName

Trzecia metoda getElementsByClassName(tag) pobiera kolekcję elementów po klasie:


document.addEventListener("DOMContentLoaded", function() {
    const buttons = document.getElementsByClassName('btn');

    for (let i=0; i<buttons.length; i++) { //pętla po wszystkich buttonach o klasie .btn
        buttons[i].style.color = "white";
    }
});

Pobieranie elementu za pomocą querySelector() i querySelectorAll()

W dzisiejszych czasach najwygodniej jest korzystać z metod querySelector(selector) i querySelectorAll(selector).

Pierwsza z nich zwraca tylko pierwszy element. Jako jej argumenty podajemy selektor CSS, który wyłapuje szukane elementy:


//pobieramy pierwszy element .btn-primary w elemencie .module
const btn = document.querySelector('.module .btn-primary');

//pobieramy pierwszy .btn w pierwszym li listy ul
const btnInFirstLi = document.querySelector('ul li:fist-of-type .btn');

//pobieram tytuł w module
const module = document.querySelector('.module');
const title = module.querySelector('.module-title');

Druga metoda querySelectorAll(selector) ma bardzo podobne działanie, z tą różnicą, że pobiera wszystkie pasujące elementy:


const btns = document.querySelectorAll('.list .btn');

for (const btn of btns) { //inny rodzaj pętli
    console.log(btn); //wypisuje dany przycisk
}

const btns = document.querySelectorAll('.module .btn');

for (const btn of btns) {
    //podpinam pod każdy przycisk zdarzenie click, które po kliknięciu pokaże text przycisku
    btn.addEventListener('click', function() {
        console.log(this.innerText);
    });
}

//pobieram moduły
const modules = document.querySelectorAll('.module');

for (let i=0; i<modules.length; i++) {
    const module = modules[i];

    //pobieramy pojedynczy przycisk danego modulu
    const moduleToggle = module.querySelector('.module-toggle');

    //dodajemy mu klik
    moduleToggle.addEventListener('click', function() {
        const title = module.querySelector('.module-title').innerText;
        console.log(title);
    });
}

Pętle po kolekcjach

Spójrz w powyższe kody. Po pobraniu elementów za pomocą metod getElementsByTagName, getElementsByClassName, querySelectorAll robiliśmy po nich pętlę - zupełnie jak po zwykłej tablicy. A jednak zamiast "tablica" notorycznie używam słowa kolekcja.
Czemu? Bo kolekcja mimo, że przypomina tablicę nią nie jest. Jak po pobraniu zbadasz taką kolekcję w debugerze okaże się, że ma sporo metod i właściwości, ale i niektórych brak.

I dla przykładu pętlę for, for of itp spokojnie po takiej kolekcji możemy robić (co powyżej już robiliśmy), ale np. map, czy filter już nie zadziałają, bo kolekcje mimo że podobne do tablic, tablicami nie są.
Z czego to wynika? Interfejs DOM mimo, że głównie wykorzystywany jest w JavaScript, nie został wcale stworzony jako ścisła część JavaScript. Interfejs ten może być spokojnie zaadoptowany przez inne języki. To JavaScript zaczął go wykorzystywać do działań na elementach.
Stąd małe różnice w tym co jest zwracane przez JavaScript, a tym co zwraca DOM. W praktyce nie ma to aż takiego znaczenia. Po prostu kolekcje to nie tablice - tak jak w wielu innych przypadkach (np. classList, arguments itp.).

Aby wykonać pętlę po elementach kolekcji możemy skorzystać z tradycyjnych pętli takich jak for czy for of:


const divs = document.querySelectorAll('.module');

for (let i=0; i<divs.length; i++) {
    divs[i].style.color = "red";
}

Dla przeglądarek wspierających ES6 warto używać pętli for of, która jest o wiele przyjemniejsza w użyciu:


const divs = document.querySelectorAll('.module');

for (const div of divs) {
    div.style.color = "red";
}

Pętli iteracyjnych po tablicach (each, some, map itp) nie będziemy mogli użyć:


//to nie zadziała, bo map jest metodą dla tablic
document.querySelectorAll('.module').map(function(el) {
    el.style...
});

Wyjątkiem jest tutaj forEach, która została w nowych przeglądarkach dodana także dla kolekcji elementów:


//to zadziała tylko w nowych przeglądarkach więc uważaj
document.querySelectorAll('.module').forEach(function(el) {
    el.style.color = "blue";
});

Aby móc użyć forEach w starszych przeglądarkach musimy skorzystać z triku, który już wcześniej poznaliśmy:


const divs = document.querySelectorAll('div.module');

[].forEach.call(divs, function(el) {
    el.style...
});

W ES6 możemy też skorzystać z innych metod by iterować po kolekcjach. Pierwszą z nich jest użycie tak zwanego spread syntax (...), który rozbija elementy tablicy na składowe (tak jakbyśmy je zapisali po przecinku 1,2,3,4), które szybko możemy zamienić na tablicę okrywając je nawiasami:


const divs = document.querySelectorAll('div.module');

[...divs].forEach(div => {
    console.log(div);
});

Drugim sposobem to użycie metody Array.from(), która zamienia element iterowalny na tablicę:


const divs = document.querySelectorAll('div.module');

Array.from(divs).forEach(div => {
    console.log(div);
});

W moim odczuciu pętla for of wydaje się najsensowniejszym rozwiązaniem. Oczywiście jeżeli nie wspierasz starych przeglądarek

:scope

Załóżmy, że mamy taki HTML:


<div class="module">
    <div>
        <div class="inside">lorem ipsum sit dolor</div>
    </div>
</div>

Pobieramy dany element:


const module = document.querySelector('.module');

Teraz chcielibyśmy wyszukać elementy w powyższym module. Odpalamy więc dla niego querySelector:


const module = document.querySelector('.module');

const div = module.querySelector('.module div div');

Jaki wynik będzie miała stała div?

Teoretycznie nie powinna wskazywać na nic, bo przecież w elemencie .module nie mamy kolejnego .module z 2 divami w sobie.

Okazuje się, że będzie wskazywać na div.inside. Selektory querySelector i querySelectorAll działają globalnie (tak samo jak selektory w CSS), to znaczy przeszukują zawsze cały dokument, a następnie odfiltrowują pasujące elementy.

W powyższym przykładzie selektor użyty w querySelector znalazł pasującego diva, a potem sprawdził czy znajduje się on w module.

Czasami niestety takie działanie może powodować nieoczekiwane wyniki:


<div class="module">
    <div data-id="one">
        <div data-id="two"> <!-- tego chcemy złapać -->
        </div>
    </div>
</div>

const module = document.querySelector('.module');

const divTwo = module.querySelector('div div');
console.log(divTwo); //<div data-id="one"></div>

Jeżeli chcemy by działanie tych selektorów odbywało się od danego rodzica (u nas .module) musimy użyć specjalnego selektora :scope


const module = document.querySelector('.module');

const div = module.querySelector(':scope div div');
console.log(divTwo); //<div data-id="two"></div>

Ale to też przypomina nam o innej rzeczy. W CSS powinniśmy jak najmniej używać stylowania po znacznikach. Zamiast tego powinniśmy kochać klasy. Sprawy stylowania to temat na całkiem długi oddzielny artykuł, ale zasada ta tyczy się i powyższego przykładu. Zamiast łapać div div, powinniśmy łapać elementy albo po ich ID, albo po ich klasach (ewentualnie jakieś customowe atrybuty). Łapanie poprzez div div nawet jak domyślnie działało by tak jak tego chcemy jest dość niepewnym manewrem (bo nie możemy zapewnić, że w przyszłości nie pojawi się tam jakiś inny div).

Wiecej na ten temat przeczytasz na stronie https://developer.rackspace.com/blog/using-querySelector-on-elements/ i https://johnresig.com/blog/thoughts-on-queryselectorall/.

Z używaniem :scope trzeba uważać, bo selektor ten nie jest wspierany w każdej przeglądarce. Na moim komputerze większość przeglądarek nie miała z nim problemu, ale już IE go nie obsługiwało. W większości przypadków nie będzie to miało znaczenia jeżeli będziesz działał na ładnej strukturze HTML i będziesz używał w HTML klas albo ID. Zamiast więc wyłapywać div div div, nadaj odpowiedniemu elementowi klasę css i złap go po tej klasie.

Żywe kolekcje

Jest jeszcze jedna istotna różnica między metodami getElementsByClassName i getElementByTagName a metodą querySelectorAll.
Te pierwsze zwracają tak zwracane żywe kolekcje. Jeżeli taką kolekcję podstawimy pod zmienną, to będzie ona automatycznie się aktualizować w zależności od elementów na stronie.

Wyobraź sobie, że za pomocą getElementsByClassName pobierzesz wszystkie elementy o klasie .module, po czym wynik takiej operacji podstawisz pod zmienną modules. Jeżeli teraz w którymkolwiek momencie zostanie usunięty jakiś element .module ze strony, zmienna ta automatycznie się zaktualizuje (zmieni się jej zawartość, length itp).

Przeprowadźmy testy.

Html dla testów:


<span class="element"></span>
<span class="element"></span>

getElementsByClassName:


const elements = document.getElementsByClassName('element');
console.log(elements.length); //2

//dynamicznie dodaję jeden .element
const elem = document.createElement('span');
elem.classList.add('element');
document.querySelector('body').appendChild(elem);

//nie ustawiam ponownie zmiennej, a mimo to jej wartość się zmieniła
console.log(elements.length); //3 - dynamicznie zwiększyła się liczba elementów

getElementsByTagName:


const elements = document.getElementsByTagName('span');
console.log(elements.length); //3

const elem = document.createElement('span');
elem.classList.add('element');
document.querySelector('body').appendChild(elem);

console.log(elements.length); //4 - dynamicznie zwiększyła się liczba elementów

querySelectorAll:


const elements = document.querySelectorAll('.element');
console.log(elements.length); //4

//dynamicznie tworzę kolejny element
const elem = document.createElement('span');
elem.classList.add('element');
document.querySelector('body').appendChild(elem);

console.log(elements.length); //4 - tutaj się dynamicznie nie aktualizuje

Wyobraź sobie teraz, że robisz pętlę po kolekcji i dynamicznie dodajesz w niej nowe elementy:


const divs = document.getElementsByTagName('div');
const body = document.querySelector('body');


for (let i=0; i<divs.length; i++) {
    const div = document.createElement('div');
    body.appendChild('div');
}

Skoro divs.length automatycznie się zwiększa po dodaniu diva, to powyższa pętla staje się z automatu pętlą nieskończoną.

Podobne żywe kolekcje zwracają metody, które pozwalają pobierać elementy w relacji do danego elementu (np. children). Przykładowo jeżeli pobierzemy wszystkie dzieci danego elementu, to usuwając ze strony któreykolwiek z tych elementów automatycznie będzie się zmieniać nasza kolekcja:


const divs = document.querySelector('div');
const children = divs.children; //pobrałem kolekcję wszystkich dzieci czyli div > *

console.log(children.length); //3
children[0].remove(); //usuwam 1 dziecko
console.log(children.length); //2

Możemy to wykorzystać dla przykładu w pętli while:


const ul = document.querySelector('.list');
const children = ul.children;

//usuwam pierwsze dziecko aż do momentu, gdy nie będzie już pierwszego dziecka
while (ul.firstElementChild !== null) {
    ul.firstElementChild.remove();
}

Zastosowanie tego w praktyce możesz zobaczyć np. przy generowaniu struktury slidera, gdzie zastosowaliśmy to przy przenoszeniu slajdów z jednego miejsca na drugie.

Trening czyni mistrza

Poniżej zamieszczam kilka zadań, które w ramach ćwiczenia możesz wykonać:

  1. Otwórz konsolę debugera i korzystając z powyżej opisanych komend znajdź h2 w elemencie o klasie .tasks-for-you-cnt.
    Wypisz je za pomocą console.log(), najedź kursorem na tekst w konsoli. Powinien się podświetlić stosowny element na stronie.
    Następnie wypisz to h2 za pomocą console.dir(). Rozwiń ten tekst i spróbuj znaleźć właściwości, za pomocą których możesz zmienić tekst w tym elemencie. Wykorzystaj je by zmienić tekst w tytule.
    
                const h2 = document.querySelector('.tasks-for-you-cnt h2);
    
                console.log(h2);
                console.dir(h2);
    
                h2.innerText = "Popsułem stronę";
                //lub
                h2.innerHTML = "Popsułem stronę";
                
  2. Ściągnij sobie przykładową stronę klikając prawym przyciskiem na link i wybierając zapisz jako.
    Wykonaj w kodzie poniższe zadania:

    1. Wypisz w konsoli pierwszy (jeden) element o klasach .module
    2. Wypisz w konsoli kolekcję elementów o klasach .module
    3. Wypisz w konsoli ostatni element o klasie .module
    4. Wypisz w konsoli wszystkie li w liście o klasie .list
    5. Wypisz w konsoli środkowe li w liście o klasie .list
    6. Wypisz w konsoli wszystkie buttony typu button
    7. Wypisz w konsoli jeden button typu submit
    8. Wypisz w konsoli kolekcję wszystkich elementów w body
    
                    console.log( document.querySelector(".module") );
    
                    const modules = document.querySelectorAll(".module");
                    console.log( modules );
                    console.log( modules[modules.length-1] );
    
                    const li = document.querySelectorAll(".list li");
                    console.log( li[li.length / 2] );
    
                    console.log( document.querySelectorAll("button[type='button']") );
                    console.log( document.querySelector("button[type='submit']") );
    
                    console.log( document.querySelectorAll("body *") );
                
  3. Przejdź do zakładki Elements w debugerze. Wybierz jakiś element z tej strony. Przejdź następnie do zakładki console. Wpisz $0. Następnie wpisz console.dir($0);