Nadążyć za skryptami na stronie - tag script i jego atrybuty

Scenariusz problemu

Najczęściej spotykanym problemem dotyczącym ładowania skryptów jest sytuacja, w której za pośrednictwem kodu w Javascript, ktoś chce pobrać element DOM, na przykład za pośrednictwem funkcji querySelector(".klasaElementu")i wykonać na nim pewne operacje.

Mimo, że w strukturze HTML element o podanej klasie istnieje, to próba jego pobrania kończy się niespodziewanym null.

Analiza strony przez przeglądarkę

Gdy przeglądarka ładuje zawartość strony i natrafi na tag <script>, to przerywa dalsze ładowanie dokumentu do momentu końca pobierania i wykonania skryptu, na który natrafiła.

To daje nam odpowiedź na pytanie, dlaczego zdarza się, że skrypt nie widzi elementów DOM, które istnieją (istnieją w dokumencie, lecz nie zostały jeszcze załadowane, przeglądarka nie wie o ich istnieniu). Dodatkowy skutek uboczny tego działania to problem zbyt długiego ładowania strony, gdy zawiera ona duży, wymagający długiego czasu uruchomienia skrypt.

Zdarzenie DOMContentLoaded

document.addEventListener("DOMContentLoaded", () => {
  console.log("DOM content is loaded!"); 
  // ... ale nie mamy informacji o stanie załadowania np. arkuszy stylów
});

Zwróćmy na moment uwagę na zdarzenie, w odnieseniu do którego przeanalizujemy możliwości wykonywania skryptów.

Ma ono miejsce w momencie, gdy cały dokument HTML zostanie załadowany i przeanalizowany przez przeglądarkę (nie uwzględnia ładowania arkuszy stylów, obrazów i ramek(<iframe>));

Czyload to alternatywa dla DOMContentLoaded?

window.addEventListener("load", () => {
  console.log("Load event!");
  // ... teraz wiemy dużo więcej!
});

Czasami możemy spotkać się z praktyką zastępowania zdarzenia DOMContentLoaded zdarzeniem load, co jest błędem i wskazuje na błędne rozumienie działania tych zdarzeń.

Jak już wspomniałem, DOMContentLoaded oczekuje na wczytanie struktury dokumentu, podczas gdy zdarzenie load oczekuje również na takie sytuacje, jak na przykład załadowanie grafik, co przy materiałach o większym rozmiarze stanowczo spowolni proces pełnego załadowania strony.

Wnioskiem, jaki możemy tutaj wysnuć jest fakt, że zdarzenie load nastąpi nie wcześniej, niż po zdarzeniu DOMContentLoaded.

Możliwe rozwiązania problemu

Oprócz oczekiwania na zdarzenie DOMContentLoadedistnieje jeszcze kilka opcji opanowania dołączanych przez nas skryptów, aby mieć nad nimi pełną (lub prawie pełną #async) kontrolę.

Umieszczenie tagów <script> na końcu dokumentu

<body>
    ...
    <script src='./scripts_1.js'></script>
</body>

Wtedy faktycznie skrypt wykona się dopiero po załadowaniu całego drzewa DOM, gdyż dopiero wtedy przeglądarka na niego natrafi. Ma to jednak swoje minusy w momencie, gdy kod naszej strony jest długi. Minie wtedy sporo czasu, zanim przeglądarka przebrnie przez strukturę strony i będzie mogła zająć się obsługą skryptów.

Atrybut defer

<head>
    ...
    <script defer src='./defer1.js'></script>
</head>

Dodanie atrybutu defer możemy rozumieć jako rozkaz dla przeglądarki: wczytuj dalszą część dokumentu, a ładowanie skryptu wykonuj "w tle". Uruchom skrypt, gdy załadujesz wszystkie skrypty z tym atrybutem oraz strukturę strony.

W praktyce scenariusz ładowania strony będzie wyglądał następująco:

  • przeglądarka rozpoczyna wczytywanie dokumentu
  • gdy natrafi na tag <script defer ...></script> przenosi jego wczytywanie w tło i kontynuuje wczytywanie dokumentu
  • gdy wczytywanie skryptów w tle się zakończy, następuje oczekiwanie na zakończenie ładowania dokumentu
  • gdy przeglądarka w pełni wczyta dokument (ale przed zdarzeniem DOMContentLoaded), uruchamia załadowane skrypty
  • po uruchomieniu skryptów następuje zdarzenie DOMContentLoaded

Uwaga: atrybutu defer możemy używać jedynie dla skryptów zewnętrznych (wczytywanych z zewnętrznych plików).

Scenariusz ten sprawi, że skrypt z atrybutem defer, mimo, że wykona się przed zdarzeniem DOMContentLoaded, to będzie miał już dostęp i informację o elementach z drzewa DOM.

Dodajmy na stronie dwa skrypty. Pierwszy skrypt bez żadnych atrybutów, drugi z atrybutem defer. Oba skrypty umieśćmy wewnątrz znacznika <head></head> oraz zamieśćmy w obu identyczny kod.

<head>
    <script src='./scripts_1.js'></script>
    <script defer src='./defer1.js'></script>
</head>
<body>
    <h1 class='title'>Title from HTML document!</h1>
</body>
console.log(`Element: ${document.querySelector(".title")}`);

W wyniku takiego działania, w konsoli przeglądarki otrzymamy:

Element: null
Element: [object HTMLHeadingElement]

Z opisanych przeze mnie wcześniej względów potwierdziliśmy, że zwykły skrypt nie poradził sobie z pobraniem elementu, za to skrypt z atrybutem defer nie miał z tym problemu.

Dodajmy teraz do zwykłego skryptu nasłuchiwanie na zdarzenie załadowania dokumentu:

console.log(`Element: ${document.querySelector(".title")}`);

document.addEventListener("DOMContentLoaded", () => {
  console.log("DOMContentLoaded!");
});

Wynik w konsoli będzie następujący:

Element: null
Element: [object HTMLHeadingElement]
DOMContentLoaded

Jak widzimy, skrypt defer uzyskał dostęp do pełni dokumentu, zanim nastąpiło zdarzenie potwierdzające jego załadowanie.

Async

<script async src='./async.js'></script>

Dodając do znacznika <script> atrybut async, informujemy przeglądarkę, że ma do czynienia ze skryptem, który powinien być w pełni niezależny od pozostałej części dokumentu.

W praktyce przeglądarka, natrafiając na tag <script async...></script>, podobnie jak w poprzednim przypadku odłoży ładowanie skryptu w tło i zajmie się dalszym wczytywaniem dokumentu.

Różnica pojawia się w momencie, gdy skrypt zostanie załadowany. W odróżnieniu od skryptów z atrybutem defer, skrypt oznaczony atrybutem async uruchomi się zaraz po załadowaniu.

Opisane zachowanie skryptu sprawia, że nie jesteśmy w stanie jednoznacznie określić, czy niezależny skrypt wykona się przed, czy po zdarzeniu DOMContentLoaded.

Dodajmy teraz do naszej strony dwa skrypty:

<head>
    ...
    <script async src='./async.js'></script>
    <script src='./scripts_1.js'></script>
</head>

Zostawmy w pliku scripts_1.js nasłuchiwanie na zdarzenie DOMContentLoaded:

document.addEventListener("DOMContentLoaded", () => {
  console.log("DOMContentLoaded!");
});

Oraz umieśćmy console.log w skrypcie z atrybutem async:

console.log("Hello from async script!");

Zaobserwujemy, że odświeżając stronę, w konsoli przeglądarki będą pojawiały się odpowiednie logi, jednak ich kolejność będzie losowa. W kolejnych odświeżeniach otrzymamy:

DOMContentLoaded!
Hello from async script!

lub

Hello from async script!
DOMContentLoaded!

co wskazuje na niezależność skryptu.

Dodatkowo, jeżeli dołączymy do strony kilka skryptów z atrybutem async, to uruchomią się one w takiej kolejności, w jakiej się załadowały. W przeciwieństwie do skryptów z atrybutem defer, nie będą one na siebie wzajemnie oczekiwały.

Use case dla skryptów asynchronicznych

Biorąc pod uwagę specyfikę skryptów z atrybutem async jasne jest, że nie powinniśmy ich używać do operacji na załadowanej strukturze strony.

Znajdują one zastosowanie w skryptach niezależnych od struktury dokumentu, na przykład w skryptach ładujących reklamy lub pobierających pewne statystyki.

Tagi artykułu: