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ć.

Dodatkowy ciekawy artykuł o DOM możesz znaleźć pod adresem: https://bitsofco.de/what-exactly-is-the-dom/

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

Obie te metody jako parametr wymagają podania selektora CSS, który wyłapie nam dane elementy.

Metoda querySelector zwraca pierwszy pasujący do selektora element, lub null, gdy nic nie znajdzie:


//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');

//pobieram element .module który nie jest divem
const module = document.querySelector('.module:not(div)');

//pobieram paragrafy, ale te które nie są pierwszym dzieckiem swojego rodzica
const p = document.querySelector('p:not(:first-child)');

Druga metoda querySelectorAll(selector) ma bardzo podobne działanie, z tą różnicą, że zwraca kolekcję elementów lub pustą kolekcję gdy nic nie znajdzie:


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 (const module of modules) {
    //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 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ą.

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 nowych przeglądarek 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";
}

Metod typowych dla tablic (filter, 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(el => {
    console.log(el);
});

[...divs].map(el => { //a jednak się da...
    console.log(el);
});

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(el => {
    console.log(el);
});

Array.from(divs).filter(el => {
    console.log(el);
});

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. Pamiętaj, że w przypadku querySelector i querySelectorAll w nawiasach podajemy selektory CSS, które wskażą na dany element. W powyższym przykładzie selektor .module div div jak najbardziej wyłapał by diva o którego nam chodziło.

Metody querySelector i querySelectorAll wykorzystują selektory CSS do złapania odpowiednich elementów, a następnie JavaScript odfiltrowuje te, które pasują w danym momencie. W powyższym przykładzie selektor .module div div złapał wszystkie elementy pasujące do tego selektora, a następnie JavaScript odfiltrował w tej kolekcji te elementy, które znajdują się w zmiennej 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órykolwiek 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.

Gotowe kolekcje

Nie każdy element na którym będziemy pracować musimy pobierać za pomocą powyższych metod.

Wiele elementów mamy już podstawione pod stosowne zmienne.

Zanim przejdziemy dalej, odpal tą stronę. i włącz debugera.

Najwyżej usytuowany jest element - window. Wpisz w konsoli debugera:


console.log(window);

Jak zobaczysz, obiekt ten ma masę różnych właściwości z których już korzystaliśmy, czy będziemy korzystali. settInterval() i setTimeout(), alert(), prompt() i confirm() czy innerWidth, innerHeight czy matchMedia to tylko niektóre z nich.

Wśród tych właściwości jest właściwość document

window - właściwość document

Wskazuje ona na obiekt document, który jest naszym dokumentem html. Jeżeli więc chciałbyś podpiąć event pod dokument, wystarczy, że wpiszesz:


window.document.addEventListener("click", function() {...});

//lub krócej:

document.addEventListener("click", function() {...});

Ale to już robiliśmy stosując event DOMContentLoaded.
Wypiszmy w konsoli ten obiekt za pomocą document.dir:


console.log(document);

Jak zobaczysz, obiekt ten także zawiera masę różnych właściwości. Wśród nich znajdują się "HTMLCollection" - czyli kolekcje elementów. Niektóre z nich to:


document.forms - formularze na stronie
document.images - grafiki img na stronie
document.links - linki na stronie
document.anchors - linki będące kotwicami
document.body - element body

Są to gotowe kolekcje zawierające dane elementy. Spróbuj w konsoli je wypisać, czy ich pojedyncze elementy:


document.all[0] - pierwszy element na stronie (html)
document.forms[0] - pierwszy formularz na stronie

document.images[0] - pierwsza grafika img na stornie
document.images[document.images.length-1] - ostatnia img na stronie

document.links[0] - pierwszy link na stronie

Ale nie są to jedyne kolekcje gotowe do użycia. Gdy dla przykładu pobierzemy ze strony jakiś formularz i wypiszemy go w konsoli, okaże się że, obiekt taki też ma swoje gotowe kolekcje np. elements, która zawiera wszystkie elementy formularza. Podobnie element select, który zawiera kolekcję options, która wskazuje na elementy options danego selekta.

Czy musisz znać te kolekcje? Nie. Zawsze możesz pobrać dane elementy korzystając z querySelectorAll czy podobnych, ale czasami warto sobie skrócić swój kod korzystając z gotowców.

Ciekawostka

Ostatnia ciekawostka na dzisiaj. JavaScript ma kilka naleciałości, które po dzień dziś gdzieś tam się ukrywają. Jednym z takich dziwactw jest to, że jeżeli stworzymy w html jakiś element z atrybutem ID, w JavaScript zostanie dla nas stworzona zmienna o takiej samej nazwie, która będzie wskazywała na ten element:


<div id="test"></div>

console.log(test);

Kolejny powód by stylować za pomocą klas...

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 ten element 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ń wypisany obiekt i spróbuj znaleźć właściwości, za pomocą których możesz zmienić tekst w tym elemencie. Szukaj po wartościach. 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 klasie .module
    2. Wypisz w konsoli kolekcję elementów o klasie .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);