Zanurz się w mrugającej wodzie wczytywania skryptów

Jake Archibald
Jake Archibald

Wstęp

W tym artykule pokażę, jak wczytać JavaScript w przeglądarce i wykonać go.

Nie, czekaj, wróć! Wiem, że może to brzmi dziwnie i prosto, ale pamiętaj, że dzieje się to w przeglądarce, w której to, co teoretycznie proste, staje się dziurą w starym stylu. Znajomość tych niuansów pozwala wybrać najszybszy i najmniej uciążliwy sposób ładowania skryptów. Jeśli masz gorę, przejdź do krótkiego podsumowania.

Na początek zobacz, jak specyfikacja określa różne sposoby pobierania i wykonywania skryptu:

WhatWG podczas wczytywania skryptu
CoWG podczas wczytywania skryptu

Podobnie jak wszystkie specyfikacje COWG, początkowo wygląda to na następstwa bomby klastrowej w fabryce scrabble, ale gdy przeczytasz ją po raz 5 i zmyjesz krew z oczu, okaże się, że jest to całkiem interesujące:

Mój pierwszy skrypt to

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

Ach, jakaby w prostocie! Przeglądarka pobierze wtedy równolegle oba skrypty i uruchomi je tak szybko, jak to możliwe, zachowując ich kolejność. Kod „2.js” nie będzie wykonywany, dopóki kod „1.js” nie zostanie wykonany (lub nie zostanie to wykonane), kod „1.js” nie zostanie wykonany do czasu wykonania poprzedniego skryptu lub arkusza stylów itd.

W międzyczasie przeglądarka blokuje dalsze renderowanie strony. Dzieje się tak dzięki interfejsom API DOM, które pochodzą z „pierwszej ery internetu”, które umożliwiają dodawanie ciągów znaków do treści przetwarzanej przez parser, np. document.write. Nowsze przeglądarki będą nadal skanować lub analizować dokument w tle i uruchamiać pobieranie potrzebnych treści zewnętrznych (js, obrazów, CSS itp.), ale renderowanie jest nadal zablokowane.

Dlatego najlepsi i najwięksi w świecie wydajności zalecają umieszczanie elementów skryptu na końcu dokumentu, ponieważ zablokuje to jak najmniejszą ilość treści. Niestety oznacza to, że przeglądarka nie widzi Twojego skryptu, dopóki nie pobierze całego kodu HTML i od tego czasu nie zacznie pobierać innych treści, np. arkuszy CSS, obrazów i elementów iframe. Nowoczesne przeglądarki są na tyle inteligentne, że nadają priorytet JavaScriptowi nad obrazami, ale stać nas na więcej.

Dzięki, IE! (nie, to nie sarkazm)

<script src="//other-domain.com/1.js" defer></script>
<script src="2.js" defer></script>

Microsoft zauważył te problemy z wydajnością i wprowadził do Internet Explorera 4 funkcję „opóźniania”. Oznacza to, że obiecuję nie wstrzykiwać treści do parsera za pomocą takich funkcji jak document.write. Jeśli nie spełnię tej obietnicy, możesz mnie ukarać, jak uznasz to za słuszne”. Ten atrybut został wdrożony w HTML4 i pojawił się w innych przeglądarkach.

W powyższym przykładzie przeglądarka pobierze oba skrypty równolegle i wykona je tuż przed uruchomieniem skryptu DOMContentLoaded, zachowując ich kolejność.

„Odkładnik” stał się jak bomba klastrowa w fabryce owiec. Między atrybutami „src” i „opóźnij” oraz tagami skryptu a skryptami dodawanymi dynamicznie mamy 6 wzorców dodawania skryptu. Oczywiście przeglądarki nie uzgodniły kolejności, w jakiej powinny być wykonywane. Mozilla napisała na ten temat świetny artykuł, który powstał w 2009 roku.

Organizacja WhatWG sygnalizowała to działanie jawnie, deklarując „opóźnianie” nie ma wpływu na skrypty, które były dodawane dynamicznie lub bez parametru „src”. W przeciwnym razie odroczone skrypty powinny być uruchamiane po przeanalizowaniu dokumentu w kolejności ich dodania.

Dzięki, IE! (ok, teraz to tylko sarkazm)

To rozdaje, i odbiera. W IE4-9 występuje błąd, który powoduje uruchamianie skryptów w nieoczekiwanej kolejności. Jak to działa:

1.js

console.log('1');
document.getElementsByTagName('p')[0].innerHTML = 'Changing some content';
console.log('2');

2.js

console.log('3');

Zakładając, że na stronie znajduje się akapit, oczekiwana kolejność dzienników to [1, 2, 3], chociaż w IE9 i starszych wersjach pojawia się wartość [1, 3, 2]. Niektóre operacje DOM powodują, że IE wstrzymuje działanie bieżącego skryptu i wykonuje inne oczekujące skrypty, zanim przejdziesz dalej.

Jednak nawet w przypadku implementacji, które nie zawierają błędów, takich jak IE10 i inne przeglądarki, wykonanie skryptu jest opóźnione do czasu pobrania i analizy całego dokumentu. Może to być wygodne, jeśli i tak chcesz poczekać na DOMContentLoaded, ale jeśli chcesz zwiększyć wydajność, możesz szybciej zacząć dodawać detektory i uruchamianie...

HTML5 na ratunek

<script src="//other-domain.com/1.js" async></script>
<script src="2.js" async></script>

HTML5 dał nam nowy atrybut „async”, który zakłada, że nie będziesz używać document.write, ale nie czeka, aż dokument zostanie przeanalizowany do wykonania. Przeglądarka pobierze równolegle oba skrypty i wykona je tak szybko, jak to możliwe.

Niestety będą one uruchamiane tak szybko, jak to możliwe, więc kod „2.js” może zostać wykonany przed „1.js”. Jest to normalne, jeśli są niezależne, być może „1.js” jest skryptem śledzenia, który nie ma nic wspólnego z „2.js”. Jeśli jednak „1.js” jest kopią jQuery, od której „2.js” nie zależy, kod „2.js” w obrębie klastra zostanie scalony w błędach w klastrze...

Wiem, czego potrzebujemy – biblioteki JavaScript.

Święty Graal polega na tym, że zestawy skryptów są pobierane natychmiast, bez blokowania renderowania, i uruchamiane tak szybko, jak to możliwe w kolejności ich dodania. Niestety HTML nienawidzi Cię i nie pozwoli Ci na to.

Ten problem rozwiązał w kilku przypadkach JavaScript. Niektóre z nich wymagały wprowadzenia zmian w kodzie JavaScript i umieszczenia go w wywołaniu zwrotnym, które wywołuje biblioteka we właściwej kolejności (np. RequireJS). Inni korzystaliby z XHR, aby pobierać dane równolegle, a potem eval() w prawidłowej kolejności, co nie działało w przypadku skryptów w innej domenie, chyba że miało nagłówek CORS, a przeglądarka go obsługiwała. Niektórzy wykorzystywali nawet supermagiczne narzędzia, takie jak LabJS.

Ataki polegały na podstępnym nakłonieniu przeglądarki do pobrania zasobu w sposób, który po zakończeniu tego procesu spowodowałby uruchomienie zdarzenia, ale nie uruchomił go. W LabJS skrypt zostanie dodany z nieprawidłowym typem MIME, np. <script type="script/cache" src="...">. Po pobraniu wszystkie skrypty są dodawane ponownie z prawidłowym typem i w nadziei, że przeglądarka pobierze je bezpośrednio z pamięci podręcznej i wykonają je natychmiast, po kolei. Było to zależne od wygodnego, ale nieokreślonego działania, które powodowało błąd, gdy przeglądarki zadeklarowane w HTML5 nie powinny pobierać skryptów o nierozpoznanym typie. Warto zwrócić uwagę, że do zmian przystosował się moduł LabJS, który obecnie korzysta z kombinacji metod opisanych w tym artykule.

Pamiętaj jednak, że te programy mają problem z wydajnością. Musisz poczekać na pobranie i przeanalizowanie kodu JavaScript biblioteki, zanim będzie można rozpocząć pobieranie skryptów, którymi zarządza. Jak zostanie wczytany program ładujący skrypty? Jak wczyta się skrypt, który informuje narzędzie ładowane? Kto ogląda Watchmen? Dlaczego jestem naga? To wszystkie trudne pytania.

Jeśli musisz pobrać dodatkowy plik ze skryptem i zanim pomyślisz o pobraniu innych skryptów, przegrywasz z grą.

DOM na ratunek

Odpowiedź znajduje się w specyfikacji HTML5, choć jest ukryta u dołu sekcji wczytywania skryptu.

Przetłumaczmy to na „Earthling”:

[
  '//other-domain.com/1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  document.head.appendChild(script);
});

Skrypty dynamicznie tworzone i dodawane do dokumentu są domyślnie asynchroniczne. Nie blokują renderowania i nie uruchamiają od razu po pobraniu, co oznacza, że mogą pojawić się w niewłaściwej kolejności. Możemy jednak wyraźnie oznaczyć je jako nieasynchroniczne:

[
  '//other-domain.com/1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.head.appendChild(script);
});

Dzięki temu nasze skrypty tworzą mieszankę zachowań, których nie da się osiągnąć przy użyciu zwykłego kodu HTML. Skrypty wyraźnie nie są asynchroniczne, więc są dodawane do kolejki wykonywania – tej samej, do której trafiły w naszym pierwszym przykładzie w formacie zwykłym HTML. Dynamicznie są one jednak tworzone, ale są wykonywane poza analizą dokumentów, więc renderowanie nie jest blokowane w czasie pobierania (nie należy mylić wczytywania nieasynchronicznego skryptu z synchronizacją XHR, ponieważ zawsze jest to zalecane).

Powyższy skrypt należy umieścić w nagłówku strony i jak najszybciej dodać go do kolejki pobierania bez zakłócania renderowania progresywnego. Skrypt uruchamia się tak szybko, jak to możliwe, we wskazanej kolejności. Kod „2.js” można pobrać bezpłatnie przed „1.js”, ale nie będzie on wykonywany, dopóki kod „1.js” nie zostanie pobrany i wykonany lub nie uda się tego zrobić. Hurra! Pobieranie asynchroniczne, ale uporządkowane!

Wczytywanie skryptów w ten sposób jest obsługiwane przez wszystko, które obsługuje atrybut async, z wyjątkiem Safari 5.0 (5.1 jest prawidłowy). Dodatkowo wszystkie wersje przeglądarek Firefox i Oper są obsługiwane, ponieważ wersje nieobsługujące atrybutu asynchronicznego w wygodny sposób wykonują skrypty dodawane dynamicznie w kolejności ich dodania do dokumentu.

Czy to najszybszy sposób na ładowanie skryptów? Prawda?

Jeśli dynamicznie decydujecie, które skrypty mają być wczytywane, tak, w przeciwnym razie prawdopodobnie nie. W powyższym przykładzie przeglądarka musi przeanalizować i wykonać skrypt, aby wykryć, które skrypty należy pobrać. Dzięki temu Twoje skrypty będą ukryte przed skanerami wstępnie wczytywanymi. Przeglądarki te używają takich skanerów do wykrywania zasobów na stronach, które prawdopodobnie odwiedzisz w następnej kolejności, lub do wykrywania zasobów stron, gdy parser jest blokowany przez inny zasób.

Możemy z powrotem zwiększyć wykrywalność, umieszczając ten kod w nagłówku dokumentu:

<link rel="subresource" href="//other-domain.com/1.js">
<link rel="subresource" href="2.js">

Informuje to przeglądarkę, że strona potrzebuje kodu 1.js i 2.js. Funkcja link[rel=subresource] jest podobna do link[rel=prefetch], ale ma inną semantykę. Obecnie jest on obsługiwany tylko w Chrome. Musisz zadeklarować, które skrypty mają być wczytywane dwukrotnie: raz za pomocą elementów link, a potem w skrypcie.

Korekta: pierwotnie wspomnieliśmy, że skaner wstępnego wczytywania wykrył te treści, ale nie są one wychwytywane przez zwykły parser. Jednak skaner wstępnego ładowania może je wykryć, ale jeszcze tego nie robi, a skrypty dołączone do kodu wykonywalnego nigdy nie mogą być wstępnie wczytywane. Dziękuję Yoav Weiss za poprawienie mnie w komentarzach.

Uważam ten artykuł za przygnębiający

Sytuacja jest przygnębiona i powinno Ci się udać. Nie ma jednoznacznego, ale deklaratywnego sposobu pobierania skryptów w sposób szybki i asynchroniczny przy zachowaniu kontroli nad kolejnością wykonywania. Dzięki protokołom HTTP2/SPDY możesz zmniejszyć obciążenie związane z żądaniami do tego stopnia, że dostarczanie skryptów w wielu małych plikach, które można przechowywać w pamięci podręcznej, może być najszybszym sposobem. Wyobraź sobie:

<script src="dependencies.js"></script>
<script src="enhancement-1.js"></script>
<script src="enhancement-2.js"></script>
<script src="enhancement-3.js"></script>
…
<script src="enhancement-10.js"></script>

Każdy skrypt ulepszeń dotyczy konkretnego komponentu strony, ale wymaga funkcji użytkowych w pliku shortcuts.js. Najlepiej jest pobrać wszystkie pliki asynchronicznie, a potem jak najszybciej uruchamiać skrypty ulepszeń w dowolnej kolejności, ale po zakończeniu zależności.js. To progresywne ulepszenie. Niestety nie można tego deklaratywnie osiągnąć, chyba że skrypty zostaną zmodyfikowane tak, aby śledziły stan wczytywania zależności.js. Nawet parametr async=false nie rozwiązuje tego problemu, ponieważ wykonanie kodueniemextension-10.js zablokuje się w okresie 1–9. W rzeczywistości jest tylko jedna przeglądarka, która pozwala to zrobić bez hakowania...

IE ma pomysł.

IE wczytuje skrypty inaczej niż inne przeglądarki.

var script = document.createElement('script');
script.src = 'whatever.js';

IE rozpoczyna teraz pobieranie pliku „whatever.js”, a inne przeglądarki zaczynają pobierać go dopiero po dodaniu skryptu do dokumentu. IE zawiera też zdarzenie „readystatechange” i właściwość „readystate”, które informują o postępie wczytywania. To naprawdę bardzo przydatne, ponieważ pozwala nam niezależnie kontrolować wczytywanie i wykonywanie skryptów.

var script = document.createElement('script');

script.onreadystatechange = function() {
  if (script.readyState == 'loaded') {
    // Our script has download, but hasn't executed.
    // It won't execute until we do:
    document.body.appendChild(script);
  }
};

script.src = 'whatever.js';

Możemy tworzyć złożone modele zależności, określając, kiedy należy dodać skrypty do dokumentu. IE obsługuje ten model od wersji 6. Jest to dość interesujące, ale nadal występuje w niej ten sam problem z wykrywalnością modułu wstępnego ładowania co w przypadku async=false.

Koniec! Jak wczytać skrypty?

OK, OK. Jeśli chcesz wczytywać skrypty w sposób, który nie blokuje renderowania, nie powtarza się i ma doskonałą obsługę przeglądarek, spróbuj wykonać te czynności:

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

To. Na końcu elementu treści. Tak, programista stron internetowych to coś w rodzaju króla Syzyfa. 100 hipsterów na artykuły związane z mitologią grecką!). Ograniczenia w języku HTML i przeglądarkach znacznie utrudniają nasze działania.

Mam nadzieję, że moduły JavaScript pozwolą nam zaoszczędzić, dając deklaratywny, nieblokujący sposób ładowania skryptów i kontrolę nad kolejnością wykonywania skryptów. Wymaga to jednak napisania skryptów w formie modułów.

Chyba musi być coś lepszego, czego możemy teraz użyć?

Jeśli chcesz wykazać się bardziej agresywnym podejściem i nie przejmujesz się złożonością i powtarzaniem, możesz połączyć kilka sztuczek wymienionych powyżej.

Najpierw dodajemy deklarację zasobu podrzędnego dla modułów wstępnego ładowania:

<link rel="subresource" href="//other-domain.com/1.js">
<link rel="subresource" href="2.js">

Następnie, bezpośrednio w nagłówku dokumentu, wczytujemy nasze skrypty w języku JavaScript za pomocą async=false, powracając do ładowania skryptu w IE i wracamy do opóźnienia.

var scripts = [
  '1.js',
  '2.js'
];
var src;
var script;
var pendingScripts = [];
var firstScript = document.scripts[0];

// Watch scripts load in IE
function stateChange() {
  // Execute as many scripts in order as we can
  var pendingScript;
  while (pendingScripts[0] && pendingScripts[0].readyState == 'loaded') {
    pendingScript = pendingScripts.shift();
    // avoid future loading events from this script (eg, if src changes)
    pendingScript.onreadystatechange = null;
    // can't just appendChild, old IE bug if element isn't closed
    firstScript.parentNode.insertBefore(pendingScript, firstScript);
  }
}

// loop through our script urls
while (src = scripts.shift()) {
  if ('async' in firstScript) { // modern browsers
    script = document.createElement('script');
    script.async = false;
    script.src = src;
    document.head.appendChild(script);
  }
  else if (firstScript.readyState) { // IE<10
    // create a script and add it to our todo pile
    script = document.createElement('script');
    pendingScripts.push(script);
    // listen for state changes
    script.onreadystatechange = stateChange;
    // must set src AFTER adding onreadystatechange listener
    // else we'll miss the loaded event for cached scripts
    script.src = src;
  }
  else { // fall back to defer
    document.write('<script src="' + src + '" defer></'+'script>');
  }
}

Później mamy 362 bajty i adresy URL skryptów:

!function(e,t,r){function n(){for(;d[0]&&"loaded"==d[0][f];)c=d.shift(),c[o]=!i.parentNode.insertBefore(c,i)}for(var s,a,c,d=[],i=e.scripts[0],o="onreadystatechange",f="readyState";s=r.shift();)a=e.createElement(t),"async"in i?(a.async=!1,e.head.appendChild(a)):i[f]?(d.push(a),a[o]=n):e.write("<"+t+' src="'+s+'" defer></'+t+">"),a.src=s}(document,"script",[
  "//other-domain.com/1.js",
  "2.js"
])

Czy warto używać dodatkowych bajtów w porównaniu z prostym skryptem? Jeśli używasz już JavaScriptu do warunkowego wczytywania skryptów, tak jak robią to BBC, warto wcześniej wywołać pobieranie. W przeciwnym razie być może nie stosuj prostej metody obejmującej całą treść.

Wiem już, dlaczego sekcja ładowania skryptu WhatWG jest tak duża. Muszę się napić.

Krótkie informacje

Zwykłe elementy skryptu

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

Według specyfikacji: pobieraj razem, wykonywaj je w kolejności po dowolnym oczekującym kodzie CSS, blokuj renderowanie do momentu zakończenia. Przeglądarki mówią: Tak, pszepana!

Odrocz

<script src="//other-domain.com/1.js" defer></script>
<script src="2.js" defer></script>

Według specyfikacji: pobieranie i wykonywanie w odpowiedniej kolejności tuż przed DOMContentLoaded. Ignoruj „opóźnienie” w skryptach bez parametru „src”. W IE < 10 mówi: mogę uruchomić kod 2.js w połowie wykonania kodu 1.js. Czy nie jest to fajne? Przeglądarki na czerwono mówią: Nie mam pojęcia, co to jest „opóźnienie”, więc wczytuję skrypty tak, jakby ich nie było. Inne przeglądarki mówią: Rozumiem, ale mogę nie zignorować „opóźniania” w przypadku skryptów bez parametru „src”.

Dane asynchroniczne

<script src="//other-domain.com/1.js" async></script>
<script src="2.js" async></script>

Według specyfikacji: pobieranie razem i wykonywanie w dowolnej kolejności. Przeglądarki na czerwono mówią: Co to jest „asynchroniczne”? Wczytam skrypty tak, jakby ich tam nie było. Inne przeglądarki mówią: Tak, OK.

Async false

[
  '1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.head.appendChild(script);
});

Specyfikacja mówi: Pobieranie razem i wykonywanie szeregu zadań zaraz po pobraniu. Firefox < 3.6, pisze Opera: Nie mam pojęcia, co to jest „asynchroniczny”, ale tak się dzieje, że uruchamiam skrypty dodane przez JS w kolejności, w której są dodawane. W Safari 5.0 piszę: rozumiem ustawienie „asynchroniczne”, ale nie rozumiem ustawienia wartości „false” (fałsz) w języku JS. Wykonam skrypty, gdy tylko będą dostępne, w dowolnej kolejności. IE < 10 mówi: nie ma pojęcia o trybie „asynchronicznym”, ale istnieje obejście polegające na użyciu zdarzenia „onreadystatechange”. W innych przeglądarkach zaznaczonej na czerwono: nie rozumiem tego „asynchronicznego” działania. Wykonam skrypty od razu po uruchomieniu, w dowolnej kolejności. Wszystkie pozostałe wypowiedzi: jestem Twoim przyjacielem i dokończymy to pod Twoją książką.