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 JS korzysta z DOM czyli Document Object Model.
Jest to model, interfejs, który za pomocą metod i właściwości umożliwia nam działanie na naszym dokumencie (czyli elementach naszej strony).

Ale cóż to za dziwo i po co komu to potrzebne?

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

Przed powstaniem DOM nasze możliwości co do manipulowania elementami na stronie były mocno ograniczone. Mieliśmy możliwość pobrania linków, formularzy i kilku innych kolekcji elementów, ale nasze możliwości były ogólnie - nie oszukujmy się - kiepskie.
Gorsze było jednak to, że w tamtych czasach przeglądarki wprowadzały swoje własne sposoby jak odwoływać się i działań na elementach dokumentu. Powodowało to, że praktycznie zawsze musieliśmy robić to na kilka sposobów.


    var id = "example"
    if (docyment.layers) { //starutkie netscape
        document.layers[id].style.display = "block";
    }

    if (document.all) { //równie stary ie
        document.all[id].style.display = "block";
    }

Gdy DOM zagościł w naszych przeglądarkach, standard ten nie tylko otworzył nam nowe lepsze możliwości, ale ujednolicił całą tą zabawę.

Uff. Tyle historii. Przechodzimy do praktyki.

Odwoływanie się do obiektów

Gdy DOM jeszcze nie istniał, odwoływanie się do obiektów wymagało korzystania ze specjalnych kolekcji. Większość obiektów na stronie pogrupowana jest w kolekcje które są swego rodzaju "tablicami". I tak dla przykładu mamy kolekcję forms, która zawiera wszystkie formularze na naszej stronie, kolekcję images, która zawiera wszystkie znaczniki IMG na naszej stronie, kolekcję links zawierającą linki itp.
Korzystanie z takich kolekcji było całkiem miłe (np. żeby pobrać 1 formularz na stronie korzystaliśmy z instrukcji forms[0]), jednak ograniczało się tylko to elementów, dla których stworzono kolekcje.

W dzisiejszych czasach takich ograniczeń nie mamy i możemy działać na każdym elemencie na stronie.

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ą JS-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 JS 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 (np. za pomocą getElementById), JS musi przeszukiwać za każdym razem całe drzewo DOM. W naszym przykładzie nie zauważymy różnicy, ale ogólnie takie wyszukiwania nie są optymalne. Jeżeli wyszukany element podstawimy pod zmienną, to tak jak w obiektach - będzie ona wskazywać dokładnie na ten element, dzięki czemu przeszukiwanie drzewa już nie będzie konieczne. Zyskujemy więc mocno na wydajności.

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 elmenetu tab. Wsystkie 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 daym 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 było robione), ale np. map już nie zadziała, bo kolekcje mimo że podobne, tablicami nie są.
Z czego to wynika? Interfejs DOM mimo, że głównie wykorzystywany jest w JS, nie został wcale stworzony jako ścisła część JS. Interfejs ten może być spokojnie zaadoptowany przez inne języki. To JS zaczął go wykorzystywać do działań na elementach. Stąd małe różnice w tym co jest zwracane przez JS, a tym co zwraca DOM. W praktyce nie ma to aż takiego znaczenia.

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";
}

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ć, ponieważ kolekcje przypominają tablice, ale nimi nie są:

//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...
});

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...
});

Dokładniej o tym triku dowiesz się tutaj.

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 - zwróci rozbite elementy kolekcji
[...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);
});

Trzeci sposób to zastosowanie pętli for of, która iteruje po obiektach iterowalnych (stringi, tablice, pseudotablice, arguments etc):


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

for (let div of divs) {
    console.log(div);
}

: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. Ściągnij sobie przykładową stronę klikając prawym przyciskiem tutaj i wybierając zapisz jako.
    Zadania wykonuj w skryptach inline pisanych na dole kodu strony.
    
                    const list = document.querySelector(".list");
                    const li = list.querySelectorAll("li");
    
                    li[2].style.setProperty("background-color", "red");
                    //lub
                    li[2].style.backgroundColor = "red";
                
  2. Za pomocą odpowiednich metod pobierz pierwszy i ostatnie LI w liście. W konsoli wyświetl wynik.
    
                    const list = document.querySelector(".list");
                    const li = list.querySelectorAll("li");
    
                    console.log( li[0] );
                    console.log( li[li.length-1] );
    
                    //lub
    
                    console.log( document.querySelector(".list > li:first-of-type");
                    console.log( document.querySelector(".list > li:last-of-type");
                
  3. Za pomocą querySelector pobierz 3 LI z listy. Skorzystaj tutaj z odpowiedniego selektora css.
    
                    const list = document.querySelector(".list");
                    const li = list.querySelectorAll("li");
    
                    console.log( li[2] );
    
                    //lub
    
                    console.log( document.querySelector(".list > li:nth-of-type(3)");
                
  4. Przejdź do zakładki Elements w debugerze. Zaznacz w tej zakładce (w widocznym drzewie) 5 przycisk na testowej stronie, którą pobrałeś w pierwszym zadaniu. Przejdź następnie do zakładki console i wpisz $0 i naciśnij enter. Co widzisz?
  5. Stwórz nowy dokument z listą .list z 10 elementami. Jeżeli używasz edytorów Visual Studio Code, WebStorm lub w innych masz zaisntalowany Emmet, wtedy możesz wpisać
    ul.list>li{Element $$$}*10 i nacisnąć TAB.

    Za pomocą JS pobierz tą listę, a następnie wszystkie jej LI. Wypisz w konsoli za pomocą instrukcji console.dir każde LI. Rozwiń je i poszperaj w wypisanych właściwościach. Wyszukaj wśród nich właściwość, która pozwoli ci wypisać w konsoli tekst danego elementu.

    html:

    
                ul.list>li{Element $$$}*10
                

    js:

    
                const ul = document.querySelector('ul.list');
                const li = ul.querySelectorAll('li');
                for (let i=0; i<li.length; i++) {
                    console.dir(li[i]);
    
                    //tutaj szperam :)
    
                    console.log("Tekst w przycisku: ", li[i].innerText);
                }