Tief eintauchen in das trüben Laden des Skripts

Jake Archibald
Jake Archibald

Einführung

In diesem Artikel zeige ich Ihnen, wie Sie JavaScript in den Browser laden und ausführen.

Nein, warte, komm zurück! Ich weiß, dass es banal und einfach klingt, aber denken Sie daran, dass dies im Browser geschieht, wo das theoretisch einfache zu einer Legacy-Darbietung wird. Wenn Sie diese Besonderheiten kennen, können Sie die schnellste und am wenigsten störende Methode zum Laden von Scripts auswählen. Wenn Sie wenig Zeit haben, springen Sie zur Kurzübersicht.

Zuerst sehen wir uns an, wie in der Spezifikation die verschiedenen Möglichkeiten zum Herunterladen und Ausführen eines Scripts definiert werden:

WHATWG zum Laden von Scripts
WHATWG zum Laden von Scripts

Wie alle WHATWG-Spezifikationen sieht es anfangs aus wie die Folgen einer Clusterbombe in einer Scrabble-Fabrik, aber wenn Sie es zum fünften Mal gelesen und sich das Blut aus den Augen gewischt haben, ist es eigentlich ziemlich interessant:

Mein erstes Script enthält

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

Herrliche Einfachheit. Hier lädt der Browser beide Skripts parallel herunter und führt sie so schnell wie möglich unter Beibehaltung ihrer Reihenfolge aus. „2.js“ wird erst ausgeführt, wenn „1.js“ ausgeführt wurde (oder dies nicht getan wurde), „1.js“ erst nach Ausführung des vorherigen Scripts oder Stylesheets usw.

Währenddessen blockiert der Browser die weitere Darstellung der Seite. Das liegt an DOM-APIs aus der „ersten Ära des Webs“, mit denen Strings an den Inhalt angehängt werden können, den der Parser verarbeitet, z. B. document.write. Neuere Browser scannen oder parsen das Dokument weiterhin im Hintergrund und lösen Downloads für externe Inhalte aus, die möglicherweise erforderlich sind (JS, Bilder, CSS usw.). Das Rendering wird jedoch weiterhin blockiert.

Aus diesem Grund empfehlen die Experten für Leistung, Scriptelemente am Ende des Dokuments einzufügen, da so möglichst wenig Inhalt blockiert wird. Leider wird Ihr Script vom Browser erst gesehen, wenn er das gesamte HTML heruntergeladen hat. Zu diesem Zeitpunkt hat er bereits mit dem Herunterladen anderer Inhalte wie CSS, Bildern und iframes begonnen. Moderne Browser sind intelligent genug, um JavaScript Bildern vorzuziehen. Wir können aber noch besser werden.

Viele Grüße (Nein, ich bin nicht sarkastisch)

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

Microsoft erkannte diese Leistungsprobleme und führte in Internet Explorer 4 die Option „defer“ ein. Dieser lautet im Grunde: „Ich verspreche, mithilfe von document.write keine Daten in den Parser einzuschleusen. Wenn ich dieses Versprechen nicht einhalte, kannst du mich auf jede Weise bestrafen, die du für angebracht hältst.“ Dieses Attribut wurde in HTML4 übernommen und erschien in anderen Browsern.

Im obigen Beispiel lädt der Browser beide Scripts parallel herunter und führt sie aus, kurz bevor DOMContentLoaded ausgelöst wird. Dabei wird die Reihenfolge beibehalten.

Wie eine Clusterbombe in einer Schaffabrik wurde „Zurückstellen“ zu einem wolligen Chaos. Es gibt sechs Möglichkeiten, ein Script hinzuzufügen: über die Attribute „src“ und „defer“ sowie über Script-Tags und dynamisch hinzugefügte Scripts. Natürlich waren sich die Browser nicht auf die Reihenfolge einig, in der sie ausgeführt werden sollten. Mozilla hat einen tollen Artikel zu dem Problem verfasst, das 2009 bestand.

Die WHATWG hat das Verhalten explizit festgelegt und erklärt, dass „defer“ keine Auswirkungen auf Scripts hat, die dynamisch hinzugefügt wurden oder kein „src“ haben. Andernfalls sollten verzögerte Scripts nach dem Parsen des Dokuments in der Reihenfolge ausgeführt werden, in der sie hinzugefügt wurden.

Viele Grüße (Okay, jetzt bin ich sarkastisch)

Sie gibt und nimmt. Leider gibt es in IE4-9 einen Programmfehler, der dazu führen kann, dass Skripts in einer unerwarteten Reihenfolge ausgeführt werden. So funktionierts:

1.js

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

2.js

console.log('3');

Angenommen, es gibt einen Absatz auf der Seite. Die erwartete Reihenfolge der Protokolle ist [1, 2, 3], obwohl in IE9 und darunter Sie [1, 3, 2] erhalten. Bestimmte DOM-Vorgänge führen dazu, dass IE die aktuelle Skriptausführung anhält und andere ausstehende Skripte ausführt, bevor Sie fortfahren.

Aber auch bei fehlerfreien Implementierungen wie IE10 und anderen Browsern wird die Scriptausführung verzögert, bis das gesamte Dokument heruntergeladen und geparst wurde. Das kann praktisch sein, wenn Sie sowieso auf DOMContentLoaded warten. Wenn Sie jedoch wirklich eine hohe Leistung erzielen möchten, können Sie schon früher mit dem Hinzufügen von Listenern und dem Bootstrapping beginnen…

HTML5 als Retter in der Not

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

HTML5 bietet das neue Attribut „async“, das davon ausgeht, dass Sie document.write nicht verwenden. Es wartet jedoch nicht, bis das Dokument geparst wurde, bevor es ausgeführt wird. Der Browser lädt beide Skripts parallel herunter und führt sie so schnell wie möglich aus.

Da sie so schnell wie möglich ausgeführt werden, wird „2.js“ möglicherweise vor „1.js“ ausgeführt. Das ist in Ordnung, wenn sie unabhängig voneinander sind. Vielleicht ist „1.js“ ein Tracking-Script, das nichts mit „2.js“ zu tun hat. Wenn „1.js“ jedoch eine CDN-Kopie von jQuery ist, von der „2.js“ abhängt, wird Ihre Seite mit Fehlern überflutet, wie eine Clusterbombe in einem… Ich weiß nicht… Ich habe nichts für diese Antwort.

Ich weiß, was wir brauchen: eine JavaScript-Bibliothek.

Das Ideal ist, dass eine Reihe von Scripts sofort heruntergeladen werden, ohne das Rendering zu blockieren, und so schnell wie möglich in der Reihenfolge ausgeführt werden, in der sie hinzugefügt wurden. Leider ist das in HTML nicht möglich.

Das Problem wurde mit JavaScript in verschiedenen Varianten angegangen. Bei einigen musste JavaScript geändert werden, indem es in einen Rückruf eingeschlossen wurde, der von der Bibliothek in der richtigen Reihenfolge aufgerufen wird (z. B. RequireJS). Andere verwendeten XHR für den parallelen Download und dann eval() in der richtigen Reihenfolge. Das funktionierte bei Scripts in einer anderen Domain nur, wenn sie einen CORS-Header hatten und der Browser dies unterstützte. Manche verwendeten sogar magische Hacks wie LabJS.

Bei den Hacks wurde der Browser dazu gebracht, die Ressource so herunterzuladen, dass beim Abschluss ein Ereignis ausgelöst, aber nicht ausgeführt wurde. In LabJS wird das Script mit einem falschen MIME-Typ hinzugefügt, z. B. <script type="script/cache" src="...">. Sobald alle Scripts heruntergeladen waren, wurden sie mit dem richtigen Typ wieder hinzugefügt, in der Hoffnung, dass der Browser sie direkt aus dem Cache abrufen und sofort in der richtigen Reihenfolge ausführen würde. Dies war von einem praktischen, aber nicht spezifizierten Verhalten abhängig und funktionierte nicht mehr, als in HTML5 festgelegt wurde, dass Browser keine Scripts mit einem nicht erkannten Typ herunterladen sollten. LabJS hat sich an diese Änderungen angepasst und verwendet jetzt eine Kombination der Methoden in diesem Artikel.

Script-Ladeprogramme haben jedoch ein eigenes Leistungsproblem: Sie müssen warten, bis das JavaScript der Bibliothek heruntergeladen und geparst wurde, bevor die von ihr verwalteten Scripts heruntergeladen werden können. Wie laden wir den Script-Lademechanismus? Wie laden wir das Script, das dem Script-Ladeprogramm mitteilt, was geladen werden soll? Wer überwacht die Wächter? Warum bin ich nackt? Das sind alles schwierige Fragen.

Wenn Sie eine zusätzliche Scriptdatei herunterladen müssen, bevor Sie überhaupt an den Download anderer Scripts denken können, haben Sie den Kampf um die Leistung bereits verloren.

Das DOM als Retter in der Not

Die Antwort findet sich in der HTML5-Spezifikation, allerdings unten im Abschnitt zum Laden von Scripts.

Lassen Sie uns das in „Earthling“ übersetzen:

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

Skripts, die dynamisch erstellt und dem Dokument hinzugefügt werden, sind standardmäßig asynchron, d. h., sie blockieren nicht das Rendering und werden sofort nach dem Download ausgeführt. Sie können also in der falschen Reihenfolge ausgegeben werden. Wir können sie jedoch explizit als nicht asynchron kennzeichnen:

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

Dadurch erhalten unsere Scripts ein Verhalten, das mit reinem HTML nicht möglich ist. Da sie explizit nicht asynchron sind, werden Scripts einer Ausführungswarteschlange hinzugefügt, derselben Warteschlange, der sie in unserem ersten Beispiel mit reinem HTML hinzugefügt werden. Da sie jedoch dynamisch erstellt werden, werden sie außerhalb des Parsens des Dokuments ausgeführt, sodass das Rendern nicht blockiert wird, während sie heruntergeladen werden. Verwechseln Sie nicht das nicht asynchrone Laden von Skripts mit dem synchronen XHR-Modus, was nie gut ist.

Das obige Script sollte inline im Head der Seiten eingefügt werden. Dadurch werden Scriptdownloads so schnell wie möglich in die Warteschlange gestellt, ohne das progressive Rendering zu unterbrechen. Das Script wird dann so schnell wie möglich in der von Ihnen angegebenen Reihenfolge ausgeführt. „2.js“ kann vor „1.js“ kostenlos heruntergeladen werden. Es wird jedoch erst ausgeführt, wenn „1.js“ heruntergeladen und ausgeführt wurde oder keines von beiden Seiten verwendet wird. Hurra! Asynchroner Download, aber geordnete Ausführung!

Das Laden von Scripts auf diese Weise wird von allen Browsern unterstützt, die das Attribut „async“ unterstützen, mit Ausnahme von Safari 5.0 (5.1 ist in Ordnung). Außerdem werden alle Versionen von Firefox und Opera unterstützt, da Versionen, die das Attribut „async“ nicht unterstützen, dynamisch hinzugefügte Scripts in der Reihenfolge ausführen, in der sie dem Dokument hinzugefügt werden.

Das ist die schnellste Methode zum Laden von Scripts, oder?

Wenn Sie dynamisch entscheiden, welche Scripts geladen werden sollen, ja. Andernfalls möglicherweise nicht. Im obigen Beispiel muss der Browser das Script parsen und ausführen, um herauszufinden, welche Scripts heruntergeladen werden sollen. Dadurch werden Ihre Skripts vor dem Vorabladen von Scannern verborgen. Browser verwenden diese Scanner, um Ressourcen auf Seiten zu erkennen, die Sie wahrscheinlich als Nächstes besuchen, oder um Seitenressourcen zu erkennen, während der Parser von einer anderen Ressource blockiert wird.

Wir können die Sichtbarkeit wieder erhöhen, indem wir diesen Code in den Kopf des Dokuments einfügen:

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

Dadurch wird dem Browser mitgeteilt, dass die Seite 1.js und 2.js benötigt. link[rel=subresource] ähnelt link[rel=prefetch], hat aber eine andere Semantik. Leider wird dies derzeit nur in Chrome unterstützt und Sie müssen angeben, welche Skripts zweimal geladen werden sollen, einmal über Link-Elemente und ein weiteres Mal in Ihrem Skript.

Korrektur: Ich hatte ursprünglich angegeben, dass diese vom Preloader-Scanner erkannt werden. Das ist nicht der Fall. Sie werden vom regulären Parser erkannt. Der Scanner zum Vorabladen könnte diese jedoch erkennen, tut dies aber noch nicht. Scripts, die in ausführbarem Code enthalten sind, können dagegen nie vorab geladen werden. Vielen Dank an Yoav Weiss, der mich in den Kommentaren korrigiert hat.

Ich finde diesen Artikel deprimierend.

Die Situation ist depressiv und du solltest dich deprimiert fühlen. Es gibt keine nicht-repetitive, aber deklarative Möglichkeit, Skripts schnell und asynchron herunterzuladen und gleichzeitig die Ausführungsreihenfolge zu steuern. Mit HTTP2/SPDY können Sie den Anfrageaufwand so weit reduzieren, dass die Bereitstellung von Skripts in mehreren kleinen, einzeln im Cache speicherbaren Dateien der schnellste Weg ist. Stellen Sie sich Folgendes vor:

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

Jedes Verbesserungsskript befasst sich mit einer bestimmten Seitenkomponente, erfordert aber Dienstfunktionen in dependencies.js. Idealerweise möchten wir alle asynchron herunterladen und dann die Verbesserungsskripts so schnell wie möglich und in beliebiger Reihenfolge ausführen, jedoch nach Abhängigkeiten.js. Es ist progressive Verbesserung! Leider gibt es keine deklarative Möglichkeit, dies zu erreichen, es sei denn, die Scripts selbst werden so geändert, dass der Ladestatus von dependencies.js erfasst wird. Auch „async=false“ löst dieses Problem nicht, da die Ausführung von „enhancement-10.js“ bei „1–9“ blockiert wird. Tatsächlich gibt es nur einen Browser, der dies ohne Hacks möglich macht...

IE hat eine Idee!

Der IE lädt Scripts anders als andere Browser.

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

Der IE startet jetzt den Download von „whatever.js“. Andere Browser starten den Download erst, wenn das Script dem Dokument hinzugefügt wurde. IE verfügt auch über ein Ereignis, „readystatechange“, und eine Eigenschaft namens „readystate“, die uns den Ladefortschritt mitteilen. Das ist sehr nützlich, da wir so das Laden und Ausführen von Scripts unabhängig steuern können.

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

Wir können komplexe Abhängigkeitsmodelle erstellen, indem wir festlegen, wann Scripts in das Dokument eingefügt werden. Dieses Modell wird von IE seit Version 6 unterstützt. Ziemlich interessant, aber sie hat immer noch das gleiche Problem mit der Preloader-Sichtbarkeit wie async=false.

Genug! Wie lade ich Skripts?

Okay, okay. Wenn Sie Scripts so laden möchten, dass das Rendering nicht blockiert wird, keine Wiederholungen erforderlich sind und eine hervorragende Browserunterstützung besteht, empfehle ich Folgendes:

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

diese Am Ende des Body-Elements. Ja, Webentwickler zu sein, ist so ähnlich wie König Sisyphus zu sein (Boom! 100 Hipsterpunkte für die Anspielung auf die griechische Mythologie!). Aufgrund von Einschränkungen in HTML und Browsern ist das nicht möglich.

Ich hoffe, dass JavaScript-Module uns helfen werden, da sie eine deklarative, nicht blockierende Möglichkeit zum Laden von Scripts und zur Steuerung der Ausführungsreihenfolge bieten. Allerdings müssen Scripts dazu als Module geschrieben werden.

Eww, es muss doch etwas Besseres geben, was wir jetzt verwenden können?

Wenn Sie die Leistung wirklich steigern möchten und ein wenig Komplexität und Wiederholung nicht stört, können Sie einige der oben genannten Tricks kombinieren.

Zuerst fügen wir die Deklaration der Unterressource für Preloader hinzu:

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

Anschließend laden wir unsere Scripts mit JavaScript inline im Head des Dokuments mit async=false und fallen auf das readyState-basierte Script-Laden des IE und dann auf „defer“ zurück.

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

Nach einigen Tricks und Minimierungen sind es 362 Byte plus Ihre Script-URLs:

!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"
])

Sind die zusätzlichen Bytes im Vergleich zu einem einfachen Script-Include die Mühe wert? Wenn Sie bereits JavaScript verwenden, um Scripts bedingt zu laden, wie es die BBC tut, können Sie auch davon profitieren, diese Downloads früher auszulösen. Andernfalls sollten Sie bei der einfachen Methode am Ende des Textkörpers bleiben.

Puh, jetzt weiß ich, warum der Abschnitt zum Laden von WHATWG-Scripts so umfangreich ist. Ich brauche einen Drink.

Kurzreferenz

Einfache Skriptelemente

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

Laut Spezifikationen: Gemeinsam herunterladen, nach ausstehendem CSS in der richtigen Reihenfolge ausführen und das Rendering bis zum Abschluss blockieren. Browser antworten: Ja, Sir!

Verschieben

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

In der Spezifikation wird Folgendes angegeben: Gemeinsam herunterladen, nacheinander ausführen, kurz vor DOMContentLoaded. Ignorieren Sie „defer“ bei Scripts ohne „src“. Im IE < 10 wird Folgendes angezeigt: 2.js wird möglicherweise zur Hälfte der Ausführung von 1.js ausgeführt. Ist das nicht toll? Die rot dargestellten Browser zeigen Folgendes an:Ich habe keine Ahnung, was das „Aussetzen“ ist. Ich werde die Skripts so laden, als gäbe es dies nicht. Andere Browser: Ok, aber ich ignoriere „defer“ bei Scripts ohne „src“ möglicherweise nicht.

Asynchron

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

Laut Spezifikation: Gemeinsam herunterladen und in beliebiger Reihenfolge ausführen. Die Browser in Rot sagen: Was bedeutet „async“? Ich lade die Scripts so, als wäre das Attribut nicht vorhanden. Andere Browser sagen: Ja, okay.

Async false

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

Laut Spezifikation: Gemeinsam herunterladen, nach dem Download der Reihe nach ausführen. Firefox < 3.6, Opera sagt: Ich habe keine Ahnung, was das „asynchron“ ist, aber ich führe jetzt über JS hinzugefügte Skripts in der Reihenfolge aus, in der sie hinzugefügt wurden. Safari 5.0: Ich verstehe „async“, aber nicht, wie ich es mit JS auf „false“ setze. Ich schreibe deine Skripts sofort nach der Veröffentlichung in beliebiger Reihenfolge. IE < 10 sagt: Keine Ahnung von „Asynchron“, aber es gibt eine Behelfslösung mit „onreadystatechange“. Andere Browser in Rot sagen: Ich verstehe das „Asynchron“-Ding nicht. Ich werde Ihre Skripts ausführen, sobald sie angezeigt werden, in welcher Reihenfolge auch immer. Alles andere sagt: Ich bin dein Freund, wir machen das nach Vorschrift.