Referencje DOM z wykorzystaniem metod tablicowych Array.prototype

Język JavaScript najczęściej uruchamiany jest w środowisku przeglądarki użytkownika. Omówimy dzisiaj możliwość wykorzystania tradycyjnych metod tablicowych do pracy z referencjami DOM aby udowodnić, że przy wielu projektach wcale nie potrzebujemy rozbudowanych bibliotek i że stronę można zakodować bez podłączania jQuery.

Na wstępie należy zaznaczyć, że artykuł ten nie jest krytyką jQuery ani całkowitym odrzuceniem tej i wielu innych bibliotek ułatwiających pracę z elementami DOM. Chcę tylko zwrócić uwagę jak można efektywnie operować DOM za pomocą samego języka JavaScript. Jako pasjonat wyrażeń regularnych nie byłbym sobą, gdybym i w tym przypadku nie wtrącił nieco tematyki RegExp… ale o tym za chwilę 🙂

Przykładowy fragment kodu HTML

W artykule będziemy odnosić się do poniższego fragmentu kodu HTML. Nie będziemy się tutaj zagłębiać w jego strukturę gdyż nie to jest przedmiotem wpisu, a dobór elementów, klas itp. został tak opracowany, aby  prosty sposób omówić metody pobierania referencji do elementów DOM i operowania na nich. Metody można łatwo przetestować wrzucając kod np. na JS Bin, gdzie mamy możliwość włączenia konsoli.

<section class="left animate-fromLeft">
    <p class="title">Wykaz owoców</p>
    <ul class="list">
        <li class="fruitA">Banan</li>
        <li class="fruitA">;Gruszka</li>
        <li class="fruitB">Jabłko polskie</li>
        <li class="fruitB">Jabłko importowane</li>
        <li class="fruitA">Truskawka</li>
        <li class="other">Borówka</li>
        <li class="other">Malina</li>
    </ul>
</section>

Przegląd wybranych metod pobierających referencje DOM

Referencje do elementów DOM można pobierać przy użyciu kilku metod obiektu document, wśród których warto wyróżnić:

  • document.getElementById()
  • document.getElementsByTagName()
  • document.getElementsByClassName()
  • document.getElementsByName()
  • document.querySelector()
  • document.querySelectorAll()

Widać zatem, że dysponujemy szeroką gamą metod w zależności od sposobu pobierania elementu. Generalnie metody „get…” są nieco szybsze od metod „query…” gdyż te drugie muszą dodatkowo przeanalizować rodzaj selektora i w zależności od tego odpowiednio „dobrać się” do DOM. Metody „get…” z góry wiedzą jakim selektorem się posługują. Ponad to najszybsza jest metoda getElementById(), co wynika z faktu, że atrybuty ID są w pewnym sensie indeksowane przez przeglądarki co pozwala na ich bardzo szybkie odnalezienie w strukturze dokumentu.

Nie będziemy jednak zajmować się tutaj szybkością działania poszczególnych metod jak również samych selektorów. Tak na marginesie warto tylko wspomnieć, że de facto łatwo możemy sobie stworzyć „mini jQuery”, np. poprzez utworzenie funkcji:

const $ = document.querySelectorAll.bind(document);
$('li.fruitA').length; //3

Powyższa metoda ma jednak pewne wady gdyż nie zwraca ona „prawdziwej” tablicy dziedziczącej po Array.prototype, podobnie jak wszystkie wymienione wczesiej metody „get…” i „query…”.

Co faktycznie zwracają metody obiektu document?

Na początek jednak mała dygresja do metody getElementById. Otóż w dokumencie powinien występować tylko jeden element z określonym ID, co jest związane z indeksowaniem tych elementów. Użycie kilka razy tego samego ID nie spowoduje błędów na stronie ale metoda getElementById i tak zwróci tylko pierwszy z tych elementów, czyli znajdujący się najwyższej w strukturze dokumentu.

Pytanie zasadnicze, co zwracają metody obiektu document? Są to pseudotablice zawierające obiekty, będące bezpośrednimi referencjami do elementów DOM. Użyłem stwierdzenia „pseudotablice”, gdyż jak za chwilę pokażę nie są to tablice, które dziedziczą „typowe” metody po Array.prototype.

let section = document.getElementsByTagName('section');
section.length; //1
Object.getPrototypeOf(section).toString();
//'[object HTMLCollection]'


let fruitA = document.getElementsByClassName('fruitA');
fruitA.length; //3
Object.getPrototypeOf(fruitA).toString();
//'[object HTMLCollection]'

//Sprawdzenie dostępnych metod tablicowych:
typeof section.forEach; //'undefined'
typeof section.map;     //'undefined'
typeof fruitA.filter;   //'undefined'
typeof fruitA.includes; //'undefined'

Widać zatem, że zwrotnie otrzymujemy obiekty HTMLCollection, które są pozbawione tradycyjnych metod z Array.prototype jak forEach, map, filter, includes, some, every, sort itp. Za chwilę pokażę jak w prosty sposób zapewnić sobie obsługę tych metod, ale wcześniej sprawdźmy, co otrzymujemy po wywołaniu metody querySelectorAll():

let list = document.querySelectorAll('li');
list.length; //7
Object.getPrototypeOf(list).toString();
//'[object NodeList]'  TUTAJ MAMY ZMIANĘ...

//Sprawdzenie dostępnych metod tablicowych:
typeof list.forEach; //'function' METODA ISTNIEJE!
typeof list.map;     //'undefined'
typeof list.filter;   //'undefined'
typeof list.includes; //'undefined'

Zwróć uwagę, że metoda querySelectorAll zwraca obiekt z NodeList , a nie HTMLCollection. Istnieje trochę różnic między nimi, ale w tej chwili najbardziej interesuje nas fakt, że pseudotablice NodeList posiadają metodę forEach. Pozwala ona przeiterować po wszystkich referencjach zwróconych metodą querySelectorAll. Metoda forEach nie zwraca jednak żadnej nowej tablicy, a jedynie operuje na istniejącej:

let list = document.querySelectorAll('.fruitA'),
    fruits = [];

list.forEach(v => fruits.push(v.innerText));
fruits; //['Banan','Gruszka','Truskawka']

Czasami jednak metoda forEach nie wystarczy…

Jak dodać obsługę metod Array.prototype w pseudotablicach?

Nie będziemy tutaj zagłębiać się w tajniki stosowania poszczególnych metod z Array.prototype. Pokażę jednak kilka przykładów jak można praktycznie wykorzystać takie metody jak map, forEach, filter, includes, some, every w tym również z użyciem wyrażeń regularnych RegExp.

No dobrze, pytanie jednak jak skorzystać z tych metod jeśli do pobierania referencji mamy tylko metody „get…” oraz „query…”. Otóż w bardzo prosty sposób. Wystarczy wykorzystać operator rest, lub starszą wersję z Array.prototype.slice.call().

let a = [...document.querySelectorAll('.fruitA')],
    b = [...document.getElementsByClassName('fruitA')];

Object.prototype.toString.call(a); //'[object Array]'
Object.prototype.toString.call(b); //'[object Array]'

typeof a.forEach;  //'function'
typeof a.map;      //'function'
typeof a.filter;   //'function'
typeof a.includes; //'function'

typeof b.forEach;  //'function'
typeof b.map;      //'function'
typeof b.filter;   //'function'
typeof b.includes; //'function'

//Alternatywnie wersja przed ECMASCript 6:
let c = Array.prototype.toString.call(document.querySelectorAll('.fruitA'));

W dobie powszechnego korzystania z transpilatorów (np. Babel) zalecam stosowanie metody wykorzystującej operator „…” (tutaj w formie operatora rest) gdyż jest to dużo czytelniejsza składnia dokładnie wskazująca zamiary programisty.

W tym momencie jedną linijką kodu zapewniliśmy sobie obsługę metod tablicowych. Większość bibliotek obsługujących DOM posiada własne metody do operowania na pseudotablicach (najczęściej o tych samych nazwach co metody Array.prototype), lecz w naszym rozwiązaniu mamy pewność co do ich działania, gdyż implementacja tych metod jest dokładnie opisana w specyfikacji ECMAScript, co stanowi podstawę ich wdrożenia w przeglądarkach.

Praktyczne wykorzystanie metody map() i forEach()

Załóżmy na przykład, że chcemy pobrać do zmiennej fruitAll referencje do wszystkich elementów li  będących potomkami elementu ul.list, a następnie w zmiennej fruitsName chcemy przechowywać tylko same nazwy owoców pisane wielkimi literami, jako ciągi znakowe (a nie obiekty):

//Wersja z metodą map()
let fruits = [...document.querySelectorAll('ul.list > li')],
    fruitsName = fruits.map(v => v.innerText.toUpperCase());

fruitsName; //["BANAN", "GRUSZKA", "JABŁKO POLSKIE", "JABŁKO IMPORTOWANE", "TRUSKAWKA", "BORÓWKA", "MALINA"]

//Wersja z metodą forEach()
let fruits = [...document.querySelectorAll('ul.list > li')],
    fruitsName = [];

fruits.forEach(v => {fruitsName.push(v.innerText.toUpperCase())});
fruitsName; //["BANAN", "GRUSZKA", "JABŁKO POLSKIE", "JABŁKO IMPORTOWANE", "TRUSKAWKA", "BORÓWKA", "MALINA"]

W przypadku metody map można by stworzyć tylko jedną zmienną fruitsName i zastosować wywołania łańcuchowe. Jest to możliwe ponieważ metoda map zwraca nową tablicę, której każdy element jest przetworzony przez funkcję callback. W metodzie forEach jest inaczej – jej zadaniem jest wywołanie dla każdego elementu funkcji callback lecz bez zwracania nowej tablicy. Jest możliwe co prawda ingerowanie w iterowaną tablicę w metodzie forEach ale osobiście uważam to za złą praktykę – do tego celu jest metoda map.

//Wersja wg mnie niepoprawna!
fruitsName.forEach((v,i,arr) => {arr[i] = v.toLowerCase()})
fruitsName; //["banan", "gruszka", "jabłko polskie", "jabłko importowane", "truskawka", "borówka", "malina"]

//Wersja poprawna z metodą map:
fruitsName = fruitsName.map(v => v.toLowerCase());
fruitsName; //["banan", "gruszka", "jabłko polskie", "jabłko importowane", "truskawka", "borówka", "malina"]

Widać również, że metoda map jest dużo czytelniejsza i od razu wskazuje na zamysł programisty.

Praktyczne zastosowanie metody filter()

Czasami zdarza się, że pobieramy referencje do większej ilości elementów DOM (jak chociażby nasz przykład z pobraniem wszystkich elementów li), lecz później do jakiś celów potrzebujemy tylko części z tych elementów.

Można to rozwiązać na kilka sposobów. Jednym z nich mogłoby być użycie metody forEach z zapisaniem w nowej zmiennej elementów, spełniających określony warunek:

let list = [...document.getElementsByTagName('li')],
    fruitA = [];

list.forEach(v => {
    if (v.className === 'fruitA') {
        fruitA.push(v);
    }
})

fruitA.length; //3 czyli jest ok

Zadanie rozwiązane, ale stworzyliśmy dość dużo kodu, praktycznie niepotrzebnego gdyż wykorzystaliśmy do tego metodę forEach, której przeznaczeniem nie jest filtrowanie tablicy. Chciałem tylko pokazać, że w programowaniu często nie ma jednego rozwiązania dla danego problemu. Jest ich kilka, a nie rzadko i kilkanaście – warto jednak wybierać rozwiązania możliwe proste i czytelne, aby w przyszłości wracając do kodu nie zastanawiać się „co autor miał na myśli…”. Spróbujmy więc metodę Array.prototype.filter:

let list = [...document.getElementsByTagName('li')],
    fruitA = list.filter(v => v.className === 'fruitA');

fruitA.length; //3

Efekt taki sam i tylko w dwóch liniach kodu. Metoda filter przetwarza każdy element tablicy funkcją callback i pozostawia go w tablicy wynikowej tylko wtedy, gdy funkcja callback zwróci dla niego wartość true. W każdym innym wypadku element zostanie „zignorowany” i nie będzie dodany do nowej tablicy. Dotyczy to jawnego zwrócenia false lub wartości, która zostanie przez silnik JS przekonwertowana do false (czyli np. gdy funkcja zwróci undefined). Metoda filter nie modyfikuje tablicy źródłowej więc w zmiennej list nadal mamy wszystkie referencje.

A co jeśli mamy wiele klas dla danego elementu…

Jak wspomniałem na początku artykułu jako pasjonat RegExp nie byłbym sobą, gdybym i dzisiaj ich nie wykorzystał 🙂 Powyższy przykład ma jeden drobny mankament, a mianowicie przyrównujemy wartość właściwości className bezpośrednio do konkretnej wartości znakowej 'fruitA'. Co jednak gdy element posiadałby przypisanych kilka klas?

Zmodyfikujmy nieco nasz kod HTML:

 
<ul class="list">
    <li class="fruitA other">Banan</li>
    <li class="fruitA other">Gruszka</li>
    <li class="fruitB">Jabłko polskie</li>
    <li class="fruitB">Jabłko importowane</li>
    <li class="fruitA">Truskawka</li>
    <li class="other">Borówka</li>
    <li class="other">Malina</li>
</ul>

Tym razem mamy również trzy elementy z przypisaną klasą fruitA, lecz dwa z nich mają jeszcze dodatkową klasę other.

let list = [...document.getElementsByTagName('li')],
    fruitA = list.filter(v => v.className === 'fruitA');

fruitA.length; //1 UPS...

Dopasowana została tylko „Truskawka”. Aby poprawnie wyfiltrować referencje do wszystkich trzech elementów pokaże dwa przykładowe rozwiązania:

let list = [...document.querySelectorAll('li')];

//Bezpośrednie pobranie nowych referencji DOM:
let fruitA = [...document.querySelectorAll('li[class*="fruitA"]')];
fruitA.length; //3

//Wykorzystanie metody filter + indexOf:
let fruitA = list.filter(v => ~v.className.indexOf('fruitA'))
fruitsA.length; //3

//Wykorzystanie metody filter + regexp:
let fruitA = list.filter(v => /fruitA/.test(v.className));
fruitA.length; //3

Który ze sposobów jest najlepszy? Według mnie nie ma tutaj jednoznacznej odpowiedzi. Jeśli nie potrzebujemy przechowywać całej listy referencji to można od razu pobrać odpowiednie elementy DOM przy wykorzystaniu selektora CSS. Jeśli jednak potrzebujemy obu zmiennych, czyli zarówno list (zawierającej wszystkie <li>) jak i przefiltrowanej tablicy fruitA to uważam, że lepszym rozwiązaniem jest operowanie na tablicy w JS zamiast kilkukrotne pobieranie referencji DOM.

No dobrze, mamy więc dwa rozwiązania – z metodą String.prototype.indexOf oraz z metodą RegExp.prototype.test. Nie sugerowałbym się tutaj faktem, że metoda test zapewnia krótszy zapis gdyż wyrażenia regularne zawsze są wolniejsze od przeszukiwania ciągów znakowych metodą indexOf.  W naszym wypadku obie metody zwrócą ten sam wynik, więc nie ma teoretycznie większego sensu używanie armaty w postaci RegExp dla prostego wyszukania "fruitA".

Potęga wyrażeń regularnych w praktyce

A co jeśli potrzebujemy pobrać elementy, które mają klasę albo "fruitA" albo "fruitB"? W tym wypadku użycie metody indexOf również byłoby możliwe, ale musielibyśmy zastosować alternatywę OR:

let list = [...document.querySelectorAll('li')],
    fruits = list.filter(v => v.className.indexOf('fruitA') !== -1
                           || v.className.indexOf('fruitB') !== -1);

fruits.length; //5

Efekt oczekiwany, czyli tablica przechowująca elementy, które posiadają albo klasę fruitA albo fruitB. Zobaczmy jak ten sam problem można rozwiązać przy użyciu prostego wyrażenia regularnego:

let list = [...document.querySelectorAll('li')],
    fruits = list.filter(v => /fruit[AB]/.test(v.className));

fruits.length; //5

Czy wersja z RegExp jest czytelniejsza? Według mnie tak, ale to już pozostawiam do oceny indywidualnej. Osobiście uważam, że warto zaprzyjaźnić się z tematyką wyrażeń regularnych gdyż pozwalają one usprawnić wiele praktycznych problemów, a co więcej, bardzo łatwo je modyfikować/rozbudowywać itp.

Metody includes(), some() oraz every()

Metoda filter pozwala na stworzenie nowej tablicy zawierającej tylko określone elementy, spełniające założone kryteria. Czasami jednak interesuje nas nie tyle zwrotna tablica lecz wyłącznie informacja, na przykład czy w tablicy list znajduje się co najmniej jeden element z klasą fruitB (tak dla odmiany zróbmy coś teraz z fruitB 🙂

W obiektach dziedziczących po Array.prototype znajduje się metoda includes ze standardu ES7. W jednej z książek o JS spotkałem się z opinią, że jest to w pewnym sensie alternatywa dla metody Array.prototype.some, lecz osobiście nie zgadzam się z tym twierdzeniem gdyż de facto metody te działają w różny sposób. Zobaczmy więc, czy uda nam się wykorzystać „nowoczesną” metodę includes:

let list = [...document.querySelectorAll('li')];
list.includes('fruitB'); //false UPS...

Niepowodzenie, ponieważ metoda includes pobiera jako pierwszy parametr ciąg znakowy do wyszukania. W naszej tablicy list znajdują się jednak przecież nie ciągi znakowe lecz obiekty (referencje DOM), więc nie ma w tablicy list elementu "fruitB". Dlatego właśnie nie uważam, aby rozsądne było przyrównywanie includes do some.

Metoda Array.prototype.some pobiera funkcję zwrotną callback. Funkcja iteruje przez kolejne elementy tablicy i jeśli dla któregoś zwróci wartość true to metoda some kończy działanie i również zwraca true. Oznacza to, że znaleziono co najmniej jeden pasujący element (dalsze nie są sprawdzane).

let list = [...document.querySelectorAll('li')];

list.some(v => ~v.className.indexOf('fruitB')); //true
list.some(v => ~v.className.indexOf('fruitC')); //false

Metoda Array.prototype.every podobnie jak some pobiera funkcję callback, ale sprawdza, czy wszystkie elementy tablicy spełniają warunek (czyli zwrócenie true przez funkcję zwrotną). Po napotkaniu na pierwszy element nie spełniający kryteriów metoda every zwraca wartość false.

A teraz wykorzystajmy potęgę RegExp…

Metoda ta umożliwia również bardziej zaawansowaną analizę naszej tablicy list. Załóżmy na przykład, że chcemy sprawdzić, czy istnieje co najmniej jeden element li, który ma przypisaną klasę fruitA oraz klasę other.

Najpierw zmodyfikujmy nieco nasz kod HTML:

 
<ul class="list">
    <li class="fruitA other">Banan</li>
    <li class="other fruitA">Gruszka</li>
    <li class="fruitB">Jabłko polskie</li>
    <li class="fruitB">Jabłko importowane</li>
    <li class="fruitA">Truskawka</li>
    <li class="other">Borówka</li>
    <li class="other">Malina</li>
</ul>

Nie możemy zastosować tutaj bezpośredniego porównania wartości className==='fruitA other' gdyż w kodzie HTML dla dwóch <li> klasy zapisaliśmy w różnej kolejności. Taki zapis klas jest niezalecany ze względu na trudności w utrzymaniu kodu. Zdarzają się jednak sytuacje, że z jakiś względów możemy chcieć zamienić kolejność klas lub po prostu dodać dodatkowe klasy (np. klasę obsługującą animację). W tym momencie kod JS staje się bezużyteczny, gdyż przyrównanie bezpośrednio wartości className zwróci false. Z pomocą przychodzą tutaj wyrażenia regularne:

let list = [...document.querySelectorAll('li')];

list.some(v => ^(?=.*other)(?=.*fruitA).*/.test(v.className));
//true

Aby upewnić się, że wyrażenie prawidłowo dopasuje oba pierwsze elementy li (gdyż tylko one mają zarówno klasę fruitA jak i other) pobierzemy sobie je metodą filter:

let list = [...document.querySelectorAll('li')],
    arr = list.filter(v => /^(?=.*other)(?=.*fruitA).*/.test(v.className));

arr.length; //2 VOILA!

Nawet jeśli później dla któregoś z elementów dodamy kolejną klasę, to nadal nasz kod JS pozostaje prawidłowy. Nie będę dzisiaj omawiał składni użytego przeze mnie wyrażenia RegExp, dla zainteresowanych wspomnę tylko, że zastosowano tu tzw. pozytywne dopasowania wyprzedzające.

Podsumowanie

W dzisiejszym wpisie chciałem pokazać, że do wielu prostych czynności związanych z operacjami na elementach DOM wcale nie potrzebujemy zaciągania wielkich bibliotek. Spotykam się często z twierdzeniem, że zapis z użyciem składni jQuery jest dużo krótszy od czystego JS. Moje pytanie w takich sytuacjach brzmi czy w tym krótszym zapisie wzięto pod uwagę fakt, że wywołanie np. document.querySelectorAll(selector) to kilkanaście znaków, a za $(selector) stoi „w tle” kilkaset jak nie kilka tysięcy znaków dodatkowej biblioteki?

Czy to oznacza, że biblioteki (np. jQuery) są złe? NIE, nic takiego nie napisałem i nigdy nie napiszę. Pytanie tylko czy na pewno tworząc prosty projekt (np. typową „wizytówkę”) na prawdę potrzebujemy bibliotek do pobrania kilku referencji DOM czy zwalidowania 2-3 pól formularza…?

Ponad to osobiście lubię mieć pełną kontrolę nad tym co projektuję. Przykładem może być chociażby precyzyjne filtrowanie elementów tablicy z użyciem metody Array.prototype.filter w połączeniu z RegExp.prototype.test. Wyrażenia regularne są potężnym narzędziem, które nadal jest za słabo rozpowszechnione wśród programistów, szczególnie języków „webowych”. RegExp są dostępne chyba w każdym języku programowania i nie tylko – można z nich korzystać nawet bezpośrednio w bazie danych (np. MySQL) projektując zapytania, procedury itp.