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 est devenue un truc qui ne passe pas inaperçu. Connaître ces particularités vous permet de choisir le moyen le plus rapide et le moins perturbateur de charger des scripts. Si vous avez un emploi du temps chargé, passez directement à la section de référence rapide.

Pour commencer, voici comment la spécification définit les différentes manières dont un script peut être téléchargé et exécuté :

Le groupe de travail WHATWG sur le chargement du script
Le WHATWG sur le chargement de script

Comme toutes les spécifications du WHATWG, le jeu ressemble au départ aux conséquences d'une bombe à clusters dans une usine de scrabble, mais une fois que vous l'avez lu pour la cinquième fois et que vous avez effacé votre sang des yeux, le résultat est en fait assez intéressant:

Mon premier script comprend

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

Ah, quelle simplicité. Le navigateur télécharge alors 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 n'a pas réussi à le faire), "1.js" ne s'exécutera pas tant que le script ou la feuille de style précédent ne s'est pas exécuté, etc.

Malheureusement, le navigateur bloque le rendu ultérieur de la page pendant tout 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 traite, comme document.write. Les navigateurs plus récents continuent d'analyser ou d'analyser le document en arrière-plan et de déclencher des téléchargements de contenus externes dont ils peuvent avoir besoin (js, images, CSS, etc.), mais le rendu est toujours bloqué.

C'est pourquoi les experts en performances recommandent de placer les éléments de script à la fin de votre document, car ils bloquent le moins de contenu possible. Malheureusement, cela signifie que votre script n'est pas vu par le navigateur tant qu'il n'a pas téléchargé tout votre code HTML. À ce stade, il commence à télécharger d'autres contenus, comme des fichiers CSS, images et iFrame. Les navigateurs modernes sont assez intelligents pour donner la priorité à JavaScript par rapport aux images, mais 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>

Microsoft a reconnu ces problèmes de performances et a introduit la fonctionnalité "defer" dans Internet Explorer 4. Cela signifie essentiellement "Je promets de ne pas injecter de contenu dans l'analyseur à l'aide d'éléments tels que document.write. Si je ne tiens pas cette promesse, vous êtes libre de me punir de la manière que vous jugerez appropriée". Cet attribut a fait son entrée dans 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, en conservant leur ordre.

Comme une bombe à fragmentation dans une usine à moutons, le mot "report" est devenu un bazar laineux. Entre les attributs "src" et "defer", et entre les balises de script et les scripts ajoutés dynamiquement, nous avons six modèles d'ajout de script. Bien sûr, les navigateurs ne s'accordaient pas sur l'ordre d'exécution. Mozilla a publié un article intéressant sur le problème tel qu'il se présentait en 2009.

Le WHATWG a rendu le comportement explicite, déclarant que "defer" n'a aucun effet sur les scripts ajoutés dynamiquement ou qui ne comportent pas de "src". Sinon, les scripts différés doivent s'exécuter après l'analyse du document, dans l'ordre dans lequel ils ont été ajoutés.

Merci, IE ! (OK, maintenant je suis sarcastique)

Il donne, il reprend. Malheureusement, un bug dans IE4-9 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 versions antérieures, vous obteniez [1, 3, 2]. Certaines opérations DOM font en sorte qu'IE mette en pause l'exécution du script en cours et exécute d'autres scripts en attente avant de continuer.

Toutefois, même dans les implémentations sans bug, telles qu'IE10 et d'autres navigateurs, l'exécution du script est retardée jusqu'à ce que l'intégralité du document ait été téléchargée et analysée. Cela peut être pratique si vous allez attendre DOMContentLoaded de toute façon, mais si vous souhaitez être vraiment agressif en termes de performances, vous pouvez commencer à ajouter des écouteurs et à effectuer le bootstrapping plus tôt.

HTML5 à la rescousse

<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'utiliserez pas document.write, mais qui n'attend pas que le document ait été analysé pour s'exécuter. Le navigateur télécharge les deux scripts en parallèle et les exécute dès que possible.

Malheureusement, comme ils s'exécutent dès que possible, "2.js" peut s'exécuter avant "1.js". Ce n'est pas un problème s'ils sont indépendants. "1.js" est peut-être un script de suivi qui n'a rien à voir avec "2.js". Mais si "1.js" est une copie CDN de jQuery dont "2.js" dépend, votre page sera recouverte d'erreurs, comme une bombe à fragmentation dans un… Je ne sais pas… Je n'ai rien pour ça.

Je sais ce qu'il nous faut : une bibliothèque JavaScript.

L'objectif est de télécharger immédiatement un ensemble de scripts sans bloquer le rendu et de les exécuter dès que possible dans l'ordre dans lequel ils ont été ajoutés. Malheureusement, HTML vous déteste et ne vous autorise pas à le faire.

Le problème a été traité par JavaScript sous plusieurs formes. Certaines d'entre elles vous obligeaient à apporter des modifications à votre code JavaScript, en l'encapsulant dans un rappel que la bibliothèque appelle dans l'ordre correct (par exemple, RequireJS). D'autres utilisaient XHR pour le téléchargement en parallèle, puis eval() dans l'ordre correct, ce qui ne fonctionnait pas pour les scripts sur un autre domaine, sauf s'ils disposaient d'un en-tête CORS et que le navigateur le prenait en charge. Certains utilisaient même des techniques ultra-magiques, comme LabJS.

Les pirates informatiques ont piégé le navigateur pour qu'il télécharge la ressource de manière à déclencher un événement à la fin, mais sans l'exécuter. Dans LabJS, le script est ajouté avec un type MIME incorrect, par exemple <script type="script/cache" src="...">. Une fois tous les scripts téléchargés, ils étaient à nouveau ajoutés avec le type correct, en espérant que le navigateur les récupérerait directement du cache et les exécuterait immédiatement, dans l'ordre. Cela dépendait d'un comportement pratique, mais non spécifié, et ne fonctionnait plus lorsque HTML5 a déclaré que les navigateurs ne devaient pas télécharger de scripts avec un type non reconnu. Notez que LabJS s'est adapté à ces modifications et utilise désormais une combinaison des méthodes décrites dans cet article.

Toutefois, les chargeurs de script présentent un problème de performances propre. Vous devez attendre que le code JavaScript de la bibliothèque soit téléchargé et analysé avant que les scripts qu'elle gère puissent commencer à être téléchargés. De plus, comment allons-nous charger le chargeur de script ? Comment allons-nous charger le script qui indique au chargeur de script ce qu'il doit charger ? Qui surveille les gardiens ? Pourquoi suis-je nu ? Ce sont toutes des questions difficiles.

En gros, si vous devez télécharger un fichier de script supplémentaire avant même de penser à télécharger d'autres scripts, vous avez déjà perdu la bataille des performances.

Le DOM à la rescousse

La réponse se trouve dans la spécification HTML5, mais elle est cachée au bas de la section sur le chargement de script.

Traduisons cela en "Earthling" :

[
  '//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 qu'ils sont téléchargés. Ils peuvent donc s'afficher dans le mauvais ordre. Toutefois, nous pouvons les marquer explicitement 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);
});

Cela permet à nos scripts d'obtenir un comportement qui ne peut pas être obtenu avec du code HTML brut. Étant explicitement non asynchrones, les scripts sont ajoutés à une file d'exécution, la même file à laquelle ils sont ajoutés dans notre premier exemple HTML simple. Toutefois, comme ils sont créés de manière dynamique, ils sont exécutés en dehors de l'analyse du document. Le rendu n'est donc pas bloqué pendant leur téléchargement (ne confondez pas le chargement de script non asynchrone avec XHR synchrone, ce qui n'est jamais une bonne chose).

Le script ci-dessus doit être inclus en ligne dans l'en-tête des pages. Il met en file d'attente les téléchargements de script dès que possible sans perturber le rendu progressif, et s'exécute dès que possible dans l'ordre que vous avez spécifié. Le téléchargement de "2.js" est sans frais avant "1.js", mais il ne s'exécutera pas tant que "1.js" n'aura pas été téléchargé et exécuté correctement ou échouera à l'une ou l'autre de ces opérations. Youpi ! Téléchargement asynchrone, mais exécution ordonnée !

Le chargement de scripts de cette manière est compatible avec tout ce qui est compatible avec l'attribut async, à l'exception de Safari 5.0 (5.1 est acceptable). En outre, toutes les versions de Firefox et d'Opera sont compatibles, car les versions non compatibles avec l'attribut "async" exécutent facilement les 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, non ? Vous me suivez ?

Si vous décidez de manière dynamique des scripts à charger, oui. Sinon, peut-être pas. Dans l'exemple ci-dessus, le navigateur doit analyser et exécuter le script pour déterminer les scripts à télécharger. Cela permet de masquer vos scripts pour les analyseurs de préchargement. Les navigateurs utilisent ces outils d'analyse pour découvrir les ressources des pages que vous êtes susceptible de consulter ensuite ou pour découvrir les ressources de la page lorsque l'analyseur est bloqué par une autre ressource.

Nous pouvons rétablir la visibilité en ajoutant ce code dans la section "head" 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 de 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 déclarer les scripts à charger deux fois, une fois via des éléments de lien et de nouveau dans votre script.

Correction : J'ai initialement indiqué que ces éléments étaient détectés par l'analyseur de préchargement, mais ce n'est pas le cas. Ils sont détectés par l'analyseur standard. Toutefois, le scanner de préchargement pourrait les détecter, mais il ne le fait pas encore, tandis que les scripts inclus par 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 aucun moyen non répétitif, mais déclaratif, 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 les frais généraux des requêtes au point où l'envoi de scripts dans plusieurs petits fichiers pouvant être mis en cache individuellement peut être le moyen le plus rapide. Imaginez :

<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 concerne un composant de page particulier, mais requiert des fonctions utilitaires dans le fichier dépendances.js. Dans l'idéal, nous devons 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 les dépendances.js. C'est une amélioration progressive de l'amélioration progressive ! Malheureusement, il n'existe aucun moyen déclaratif d'y parvenir, sauf si les scripts eux-mêmes sont modifiés pour suivre l'état de chargement du fichier dépendances.js. Même la valeur "async=false" ne résout pas ce problème, car l'exécution de enhancement-10.js sera bloquée sur 1-9. En fait, il n'existe qu'un seul navigateur qui permet cela 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';

IE commence à télécharger "whatever.js" dès maintenant, tandis que les autres navigateurs ne commencent à le télécharger qu'après avoir ajouté le script au document. IE dispose également d'un événement "readystatechange" et d'une propriété "readystate" qui 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 de façon indépendante.

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. IE est compatible avec ce modèle depuis la version 6. Plutôt intéressant, il souffre toujours du même problème de visibilité du pré-chargeur que async=false.

Assez ! Comment charger des scripts ?

OK. Si vous souhaitez charger des scripts sans bloquer le rendu, sans répétition et avec 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 body. Oui, être développeur Web, c'est un peu comme être le roi Sisyphe (boum ! 100 points hipster pour la référence à la mythologie grecque !) Les limites du langage HTML et des navigateurs nous empêchent d'aller plus loin.

J'espère que les modules JavaScript nous sauveront en fournissant un moyen déclaratif et non bloquant de charger des scripts et de contrôler l'ordre d'exécution, bien que cela nécessite que les scripts soient écrits en tant que modules.

Oh non, il doit y avoir une meilleure solution.

Si vous souhaitez vraiment améliorer les performances et que vous n'avez pas peur de la complexité et de la répétition, vous pouvez combiner plusieurs des astuces ci-dessus.

Tout d'abord, nous ajoutons 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, dans la section "head" du document, nous chargeons nos scripts avec JavaScript, à l'aide de async=false, en recourant au chargement de script basé sur le readystate d'IE, puis en recourant au différé.

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

Voici quelques astuces et la minimisation par la suite : la taille du fichier est 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 la peine d'utiliser des 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 la BBC, vous pouvez déclencher ces téléchargements plus tôt. Sinon, vous pouvez utiliser la méthode simple de fin de corps.

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

Référence rapide

Éléments de script bruts

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

Spécification : Téléchargement groupé, exécution dans l'ordre après tout CSS en attente, blocage du rendu jusqu'à la fin. Les navigateurs répondent : Oui, monsieur !

Reporter

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

Spécification : Téléchargez-les ensemble, exécutez-les dans l'ordre juste avant DOMContentLoaded. Ignorez "defer" sur les scripts sans "src". IE < 10 indique : Je peux exécuter 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 que vous pouvez reporter". Je vais charger les scripts comme s'ils n'étaient pas là. Les autres navigateurs répondent : OK, mais je ne vais peut-être pas ignorer "defer" sur les scripts sans "src".

Asynchrone

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

La spécification indique : "Télécharger simultanément", s'exécuter dans l'ordre dans lequel ils effectuent le téléchargement. Les navigateurs en rouge disent : Qu'est-ce que "async" ? Je vais charger les scripts comme s'il n'y avait pas de "async". D'autres navigateurs disent : "Oui, d'accord."

Asynchrone (false)

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

Spécification : Téléchargement groupé, exécution dans l'ordre dès que tous les fichiers sont téléchargés. Firefox < 3.6, Opera indique : Je n'ai aucune idée de ce que cette chose "async" est, mais il se trouve que j'exécute les scripts ajoutés via JS dans l'ordre où ils sont ajoutés. Safari 5.0 indique : Je comprends "async", mais je ne comprends pas comment le définir sur "false" avec JS. J'exécuterai vos scripts dès qu'ils arriveront, dans l'ordre que vous souhaitez. IE < 10 indique : Je ne comprends pas ce que signifie "async", mais il existe une solution de contournement à l'aide de "onreadystatechange". Les autres navigateurs en rouge indiquent : Je ne comprends pas ce que signifie "async". J'exécuterai vos scripts dès qu'ils arriveront, dans l'ordre que vous voulez. Tout le reste dit : je suis ton ami, nous allons faire ça dans les règles.