Plongez dans les eaux troubles du chargement des scripts

Jake Archibald
Jake Archibald

Introduction

Dans cet article, je vais vous apprendre à charger du code JavaScript dans le navigateur et à l'exécuter.

Non, attends, reviens ! Je sais que cela peut sembler banal et simple, mais n'oubliez pas que cela se produit dans le navigateur, où la simplicité théorique devient un trou de bizarrerie héritée. Connaître ces particularités vous permet de choisir le moyen le plus rapide et le moins gênant de charger des scripts. Si votre emploi du temps est chargé, passez directement au guide de référence.

Pour commencer, voici comment la spécification définit les différents modes de téléchargement et d'exécution d'un script:

Le WHATWG sur le chargement des scripts
Le WHATWG sur le chargement des scripts

Comme toutes les spécifications de WHATWG, elle ressemble à l'origine d'une bombe à clustering dans une usine de braquage, mais une fois que vous l'avez lue pour la cinquième fois et que vous avez effacé le sang de vos yeux, c'est en fait très intéressant:

Mon premier script comprend

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

Ah, une simplicité délicieuse. Le navigateur télécharge les deux scripts en parallèle et les exécute dès que possible, en conservant leur ordre. "2.js" ne s'exécutera pas tant que "1.js" ne s'est pas exécuté (ou non), "1.js" ne s'exécutera pas tant que le script ou la feuille de style précédents n'aura pas été exécuté, etc.

Malheureusement, le navigateur bloque l'affichage de la page pendant ce processus. Cela est dû aux API DOM de la "première ère du Web" qui permettent d'ajouter des chaînes au contenu que l'analyseur mâche, comme document.write. Les navigateurs récents continueront d'analyser le document en arrière-plan et de déclencher le téléchargement de contenus externes dont il pourrait avoir besoin (js, images, CSS, etc.), mais l'affichage restera bloqué.

C'est pourquoi les meilleurs et les meilleurs acteurs du monde de la performance recommandent de placer des éléments de script à la fin de votre document, car cela bloque le moins de contenu possible. Malheureusement, cela signifie que votre script n'est pas visible par le navigateur tant qu'il n'a pas téléchargé tout votre code HTML, et qu'il a alors commencé à télécharger d'autres contenus, tels que des fichiers CSS, des images et des iFrames. Les navigateurs récents sont suffisamment intelligents pour donner la priorité à JavaScript aux images. Toutefois, nous pouvons faire mieux.

Merci IE ! (Non, je ne suis pas sarcastique.)

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

Conscient de ces problèmes de performances, Microsoft a introduit la fonction "différer" dans Internet Explorer 4. Il indique en gros : "Je promets de ne pas injecter d'éléments dans l'analyseur à l'aide d'éléments tels que document.write. Si je ne respecte pas cette promesse, vous êtes libre de me punir comme vous le jugez nécessaire." Cet attribut est passé au format HTML4 et est apparu dans d'autres navigateurs.

Dans l'exemple ci-dessus, le navigateur télécharge les deux scripts en parallèle et les exécute juste avant le déclenchement de DOMContentLoaded, tout en conservant leur ordre.

Comme une bombe à cluster dans une usine de moutons, le terme "différer" est devenu un gâchis laineux. Entre les attributs "src" et "defer", et les balises de script et les scripts ajoutés dynamiquement, il existe six modèles d'ajout de script. Bien sûr, les navigateurs n'étaient pas d'accord sur l'ordre dans lequel ils devraient s'exécuter. Mozilla a rédigé un excellent article sur ce problème qui remonte à 2009.

Le WHATWG a rendu le comportement explicite en déclarant que "différer" n'avait aucun effet sur les scripts qui ont été ajoutés dynamiquement ou pour lesquels "src" manquait. Sinon, les scripts différés devraient s'exécuter après l'analyse du document, dans l'ordre dans lequel ils ont été ajoutés.

Merci IE ! (D'accord, je suis sarcastique.)

Il donne, il s'en sort. Malheureusement, il existe un nuisible bug dans IE4-9 qui peut entraîner l'exécution des scripts dans un ordre inattendu. Voici ce qui se passe:

1.js

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

2.js

console.log('3');

En supposant qu'il y ait un paragraphe sur la page, l'ordre attendu des journaux est [1, 2, 3], bien que dans IE9 et les versions antérieures, vous obtenez [1, 3, 2]. Certaines opérations DOM entraînent la suspension de l'exécution du script en cours dans IE et l'exécution d'autres scripts en attente avant de poursuivre.

Toutefois, même dans les implémentations qui ne génèrent pas de bugs, comme IE10 et d'autres navigateurs, l'exécution du script est retardée jusqu'à ce que l'intégralité du document soit téléchargée et analysée. Cela peut être pratique si vous prévoyez quand même d'attendre DOMContentLoaded, mais si vous voulez être très agressif au niveau des performances, vous pouvez commencer à ajouter des écouteurs et à amorcer votre campagne plus rapidement...

La solution HTML5

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

HTML5 nous a fourni un nouvel attribut, "async", qui suppose que vous n'allez pas utiliser document.write, mais n'attend pas que le document soit analysé pour s'exécuter. Le navigateur téléchargera les deux scripts en parallèle et les exécutera dès que possible.

Malheureusement, comme ils vont s'exécuter dès que possible, "2.js" peut s'exécuter avant "1.js". Cela ne pose pas de problème s'ils sont indépendants. Il se peut que "1.js" soit un script de suivi qui n'a rien à voir avec "2.js". Mais si "1.js" est une copie CDN de jQuery dont dépend "2.js", votre page ne risque rien d'être une bombe de cluster.

Je sais ce qu'il nous faut, une bibliothèque JavaScript !

Le pari est qu'un ensemble de scripts se télécharge immédiatement sans bloquer l'affichage et s'exécute dès que possible, dans l'ordre dans lequel ils ont été ajoutés. Malheureusement, HTML vous déteste et ne vous le permet pas.

Le problème a été résolu par JavaScript sous plusieurs formes. Certains exigeaient que vous apportiez des modifications au code JavaScript, en l'encapsulant dans un rappel que la bibliothèque appelle dans le bon ordre (par exemple, RequireJS). D'autres utilisaient XHR pour télécharger en parallèle, puis eval() dans le bon ordre. Cela ne fonctionnait pas pour les scripts d'un autre domaine, sauf s'ils avaient un en-tête CORS et si le navigateur le prenait en charge. Certains ont même utilisé des techniques de super-magie, comme LabJS.

Le piratage consistait à inciter le navigateur à télécharger la ressource d'une manière qui déclencherait un événement une fois qu'il était terminé, mais en évitant de l'exécuter. Dans LabJS, le script serait ajouté avec un type MIME incorrect, par exemple <script type="script/cache" src="...">. Une fois tous les scripts téléchargés, ils étaient de nouveau ajoutés avec le type correct, en espérant que le navigateur les récupère directement du cache et les exécute immédiatement, dans l'ordre. Cela dépendait d'un comportement pratique, mais non spécifié, et fonctionnait lorsque les navigateurs déclarés HTML5 ne devaient pas télécharger de scripts dont le type n'était pas reconnu. Il est à noter que LabJS s'est adapté à ces changements et utilise désormais une combinaison des méthodes décrites dans cet article.

Toutefois, les chargeurs de scripts rencontrent eux-mêmes un problème de performances. Vous devez attendre que le code JavaScript de la bibliothèque soit téléchargé et analysé pour que le téléchargement de l'un des scripts qu'il gère puisse commencer. Comment allons-nous charger le chargeur de script ? Comment allons-nous charger le script qui indique au chargeur de script les éléments à charger ? Qui regarde les Watchmen ? Pourquoi suis-je nu ? Ce sont toutes des questions difficiles.

En fait, si vous devez télécharger un fichier de script supplémentaire avant même d'envisager de télécharger d'autres scripts, vous perdez la bataille des performances.

Le DOM à la rescousse

La réponse se trouve en fait dans la spécification HTML5, bien qu'elle soit masquée au bas de la section de chargement du script.

Reprenons cela en "terrain" :

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

Les scripts créés et ajoutés dynamiquement au document sont asynchrones par défaut. Ils ne bloquent pas l'affichage et s'exécutent dès leur téléchargement, ce qui signifie qu'ils peuvent apparaître dans le mauvais ordre. Cependant, nous pouvons explicitement les marquer comme non asynchrones:

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

Les scripts présentent ainsi un comportement qui ne peut pas être obtenu avec du code HTML brut. Comme ils ne sont explicitement pas asynchrones, les scripts sont ajoutés à une file d'attente d'exécution, à laquelle ils sont ajoutés dans notre premier exemple en HTML brut. Cependant, les créations de ce type étant créées dynamiquement, elles sont exécutées en dehors de l'analyse du document, ce qui évite de bloquer leur affichage pendant leur téléchargement (ne confondez pas le chargement de script non asynchrone avec le chargement XHR synchronisé, ce qui n'est jamais une bonne chose).

Le script ci-dessus doit être intégré dans l'en-tête des pages. Il doit être téléchargé dès que possible sans interrompre l'affichage progressif et s'exécuter dès que possible dans l'ordre que vous avez spécifié. La version "2.js" peut être téléchargée sans frais avant "1.js", mais elle ne sera exécutée qu'une fois que "1.js" aura été téléchargé et exécuté correctement, ou qu'elle ne fonctionnera pas correctement. Hourra ! Téléchargement asynchrone, mais exécution ordonnée !

Cette méthode est compatible avec tous les éléments compatibles avec l'attribut "async", à l'exception de Safari 5.0 (la version 5.1 convient parfaitement). De plus, toutes les versions de Firefox et d'Opera sont compatibles, car les versions ne prenant pas en charge l'attribut "async" exécutent de toute façon pratique des scripts ajoutés dynamiquement dans l'ordre dans lequel ils sont ajoutés au document.

C'est le moyen le plus rapide de charger des scripts, n'est-ce pas ? Vous me suivez ?

Si vous décidez dynamiquement des scripts à charger, oui, sinon peut-être pas. Dans l'exemple ci-dessus, le navigateur doit analyser et exécuter le script pour identifier ceux à télécharger. Ainsi, les analyseurs de préchargement ne verront pas vos scripts. Les navigateurs utilisent ces analyseurs pour découvrir les ressources des pages susceptibles de vous intéresser ou pour identifier les ressources des pages lorsque l'analyseur est bloqué par une autre ressource.

Nous pouvons rajouter la visibilité en plaçant ceci dans l'en-tête du document:

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

Cela indique au navigateur que la page a besoin des versions 1.js et 2.js. link[rel=subresource] est semblable à link[rel=prefetch], mais avec une sémantique différente. Malheureusement, cette fonctionnalité n'est actuellement compatible qu'avec Chrome. Vous devez donc déclarer les scripts à charger deux fois, une fois via des éléments "link", puis une nouvelle fois dans votre script.

Correction:j'ai initialement indiqué qu'elles étaient récupérées par l'analyseur de préchargement. Elles ne le sont pas, elles le sont par l'analyseur standard. Toutefois, le scanner de préchargement pourrait les détecter, mais il ne l'a pas encore fait, alors que les scripts inclus dans le code exécutable ne peuvent jamais être préchargés. Merci à Yoav Weiss qui m'a corrigé dans les commentaires.

Je trouve cet article déprimant.

La situation est déprimante et vous devriez vous sentir déprimé. Il n'existe pas de méthode non répétitive, mais déclarative, permettant de télécharger des scripts rapidement et de manière asynchrone tout en contrôlant l'ordre d'exécution. Avec HTTP2/SPDY, vous pouvez réduire la surcharge des requêtes au point que la livraison de scripts dans plusieurs petits fichiers pouvant être mis en cache individuellement peut être la méthode la plus rapide. Imaginons que :

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

Chaque script d'amélioration traite d'un composant de page particulier, mais nécessite des fonctions utilitaires dans dépendances.js. Idéalement, nous souhaitons tout télécharger de manière asynchrone, puis exécuter les scripts d'amélioration dès que possible, dans n'importe quel ordre, mais après dépendances.js. Il s'agit d'une amélioration progressive ! Malheureusement, il n'existe aucun moyen déclaratif d'y parvenir, sauf si les scripts eux-mêmes sont modifiés de façon à suivre l'état de chargement de dépendances.js. Même async=false ne permet pas de résoudre ce problème, car l'exécution d'enhanced-10.js bloquera sur 1-9. D'ailleurs, il n'existe qu'un seul navigateur qui rend cela possible sans piratage...

IE a une idée !

IE charge les scripts différemment des autres navigateurs.

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

Internet Explorer commence maintenant à télécharger le fichier "whatever.js". Les autres navigateurs ne le téléchargent pas tant que le script n'a pas été ajouté au document. IE comporte également un événement "readystatechange" et une propriété "readystate" qui nous indiquent la progression du chargement. Cette fonctionnalité est très utile, car elle nous permet de contrôler le chargement et l'exécution des scripts indépendamment.

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

Nous pouvons créer des modèles de dépendances complexes en choisissant quand ajouter des scripts dans le document. Ce modèle est compatible avec Internet Explorer depuis la version 6. Plutôt intéressant, mais il présente toujours le même problème de visibilité du pré-chargeur que async=false.

Ça suffit ! Comment charger des scripts ?

D'accord, d'accord. Si vous souhaitez charger les scripts sans bloquer l'affichage, sans répétition et offrant une excellente compatibilité avec les navigateurs, voici ce que je vous propose:

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

cette À la fin de l'élément du corps. Oui, être développeur Web, c'est comme être le roi Sisyphe (boum ! 100 points de type hipster pour référence à la mythologie grecque). Les limitations du code HTML et des navigateurs nous empêchent d'améliorer considérablement notre service.

J'espère que les modules JavaScript nous feront économiser en offrant un moyen déclaratif et non bloquant de charger des scripts et de contrôler l'ordre d'exécution, bien que les scripts soient écrits sous forme de modules.

Oh, y a-t-il quelque chose de mieux à utiliser maintenant ?

Très bien. Pour obtenir des points bonus, si vous voulez être très agressif concernant les performances, et que cela ne vous dérange pas un peu de complexité et de répétition, vous pouvez combiner plusieurs des astuces ci-dessus.

Tout d'abord, ajoutez la déclaration de sous-ressource pour les pré-chargeurs:

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

Ensuite, de manière intégrée dans l'en-tête du document, nous chargeons nos scripts avec JavaScript à l'aide de async=false, en revenant au chargement de script basé sur "readystate" d'IE et en revenant à différer.

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

Quelques astuces et minimisation du fichier par la suite : il s'agit de 362 octets + les URL de vos scripts :

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

Cela vaut-il mieux les octets supplémentaires par rapport à une simple inclusion de script ? Si vous utilisez déjà JavaScript pour charger des scripts de manière conditionnelle, comme le fait la BBC, vous avez tout intérêt à déclencher ces téléchargements plus tôt. Sinon, vous pouvez vous en tenir à la méthode simple de la fin du corps.

Ouf, maintenant je sais pourquoi la section de chargement du script WHATWG est si vaste. J'ai besoin d'un verre.

Guide de référence rapide

Éléments de script brut

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

La spécification indique:Téléchargez ensemble, exécutez dans l'ordre après tout CSS en attente, bloque l'affichage jusqu'à la fin de l'opération. Les navigateurs disent:Oui, monsieur !

Reporter

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

Spécification: Téléchargez ensemble, exécutez dans l'ordre juste avant "DOMContentLoaded". Ignorez "defer" sur les scripts sans "src". Le message IE < 10 indique: il se peut que je exécute 2.js à mi-parcours de l'exécution de 1.js. N'est-ce pas amusant ? Les navigateurs en rouge indiquent: "Je n'ai aucune idée de ce qu'est ce "différer" ; je vais charger les scripts comme s'ils n'étaient pas là. D'autres navigateurs indiquent : "OK", mais je ne peux pas ignorer le paramètre "différer" pour les scripts sans "src".

Async

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

La spécification indique : "Téléchargez ensemble les fichiers, exécutez-les dans l'ordre dans lequel ils se téléchargent". Les navigateurs en rouge indiquent: Qu'est-ce que "async" ? Je vais charger les scripts comme s'ils n'étaient pas là. D'autres navigateurs indiquent: Oui, d'accord.

Async false

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

La spécification indique : "Télécharger ensemble, exécuter dans l'ordre dès que tout le téléchargement". Firefox < 3.6, dit Opera : "Je n'ai aucune idée de ce qu'est cette chose "asynchrone", mais il se trouve que j'exécute les scripts ajoutés via JavaScript dans l'ordre dans lequel ils sont ajoutés. Message de la version 5.0 de Safari:Je comprends le terme "async", mais je ne comprends pas le paramétrage du code sur "false" avec JS. J'exécute vos scripts dès qu'ils arrivent, dans n'importe quel ordre. IE < 10 dit: Aucune idée de "async", mais il existe une solution de contournement grâce à "onreadystatechange". D'autres navigateurs en rouge indiquent: "Je ne comprends pas ce problème. Je vais exécuter vos scripts dès qu'ils arrivent, dans n'importe quel ordre. Tout le reste dit: Je suis votre ami, nous allons le faire avec le livre.