Comment JavaScript s'exécute dans le navigateur

Comment le navigateur construit le DOM à partir du HTML, dans quel ordre il traite link et script, l'attribut defer, la console des DevTools et la vérification des résultats en texte.

Du code JavaScript qui modifie un texte de la page peut marcher ou non selon l'endroit où tu le charges. Quand il ne marche pas, aucune erreur ne s'affiche sur la page : impossible de savoir si le code lui-même est faux ou si le problème vient de l'ordre d'exécution.

Cet article explique dans quel ordre le navigateur charge le HTML et exécute le JavaScript.

Le code de cet article s'ouvre sous forme de page HTML

La console des exercices ne peut pas reproduire l'ordre de chargement du HTML : cet article n'a donc pas d'exercices. Chaque exemple montre ce qui se passe quand tu places index.html et app.js dans le même dossier et que tu ouvres index.html dans Chrome ; les résultats sont indiqués en commentaire en fin de ligne. La façon de vérifier dans la console est expliquée dans la dernière section.

Des éléments imbriqués construits à partir du HTML — l'arbre DOM

Imagine qu'app.js doive remplacer le texte « Calcul en cours… » d'une page de confirmation de commande par « 3 300 yens ». Ce que JavaScript modifie ici, ce n'est pas le texte du fichier index.html, mais une autre structure de données, que le navigateur a construite en lisant le HTML.

Le navigateur transforme chaque élément HTML (un morceau de la page délimité par des balises, comme <p></p>) en objet, et l'ensemble de leurs relations parent-enfant s'appelle le DOM (Document Object Model, aussi appelé arbre DOM ; on y accède par document).

Les informations ajoutées dans une balise, comme id="total" ou defer, s'appellent des attributs. Un id doit être unique : aucun autre élément de la page ne peut porter le même.

<!-- ---- index.html (page de confirmation de commande) ---- -->
<!DOCTYPE html>
<html lang="fr">
  <head>
    <meta charset="UTF-8">
    <title>Confirmation de commande</title>
    <link rel="stylesheet" href="style.css">   <!-- le CSS qui définit l'apparence -->
  </head>
  <body>
    <h1>Confirmation de commande</h1>
    <ul id="items">                           <!-- contient deux li (ses enfants) -->
      <li>Mug × 2</li>
      <li>Dessous de verre × 1</li>
    </ul>
    <p id="total">Calcul en cours…</p>         <!-- l'élément qu'app.js veut modifier -->
  </body>
</html>
L'arbre DOM issu d'index.html
élément html — sommet du DOM, accessible depuis document
élément head — infos non affichées
  • meta — l'encodage est UTF-8
  • title — contient « Confirmation de commande »
  • link — pointe vers style.css
élément body — la partie affichée
élément h1
  • Contient « Confirmation de commande »
élément ul — id vaut items
  • li — contient « Mug × 2 »
  • li — contient « Dessous de verre × 1 »
élément p — id vaut total
  • Contient « Calcul en cours… »
  • L'élément qu'app.js veut modifier
head et body sont côte à côte, enfants de html, et les li se trouvent dans ul. L'imbrication des balises devient telle quelle la structure parent-enfant du DOM.

app.js récupère l'élément p avec document.querySelector("#total") (qui cherche l'élément dont l'id est écrit après le #, et renvoie null s'il n'y en a pas), puis modifie son textContent (le texte contenu dans l'élément). Comme l'élément p est un enfant de body, il n'est trouvé que si body est déjà dans le DOM.

Le HTML se lit de haut en bas — l'ordre de link et script

Suppose que tu charges app.js, qui met à jour le total, dans <head>, à côté du CSS. index.html contient bien <p id="total">, et pourtant la page affiche toujours « Calcul en cours… » : la modification d'app.js n'a aucun effet.

Pendant l'analyse du HTML (le navigateur lit le HTML depuis le début et ajoute au DOM, dans l'ordre, chaque élément qu'il rencontre), quand il tombe sur un <link>, il continue l'analyse tout en récupérant le CSS. Quand il tombe sur un <script src>, il suspend l'analyse, récupère le JavaScript et l'exécute jusqu'au bout avant de lire la suite.

// ---- index.html (app.js chargé dans head) ----
// <head>
//   <link rel="stylesheet" href="style.css">   ← l'analyse continue
//   <script src="app.js"></script>             ← l'analyse s'arrête pour l'exécuter
// </head>
// <body>
//   <p id="total">Calcul en cours…</p>          ← ajouté au DOM après app.js
// </body>

// ---- app.js ----
const total = document.querySelector("#total");   // null (pas encore dans le DOM)
total.textContent = "3 300 yens";
// TypeError: Cannot set properties of null (setting 'textContent')
Le p n'existe pas encore quand le script s'exécute
<link href="style.css">Récupèrele CSSL'analyse passeà la suite<script src="app.js">Suspend l'analyse,exécute app.jsPas de #total,null renvoyé<p id="total">Reprend l'analyse,l'ajoute au DOM#total arrivetrop tard</html>Fin de l'analyse,DOM completAffiche encore« Calculen cours… »
L'analyse s'arrête à la ligne du script et ne lit body qu'après l'exécution d'app.js. Même si app.js lève une erreur, l'analyse reprend et la page s'affiche.

La modification n'a pas marché parce que l'élément p n'était pas encore dans le DOM au moment où app.js s'est exécuté. Dans la section suivante, on garde les deux lignes d'app.js telles quelles et on repousse simplement leur exécution à la fin de la construction du DOM.

Attendre que le DOM soit complet — l'attribut defer

Si tu déplaces le <script> juste avant </body>, il s'exécute après la lecture de l'élément p, et #total est trouvé. Mais le bon fonctionnement dépend alors de l'emplacement de la balise : si un jour tu regroupes la ligne de chargement avec le CSS dans <head>, tu obtiens de nouveau null.

L'attribut defer (une option de <script> qui récupère le JavaScript sans suspendre l'analyse du HTML et ne l'exécute qu'une fois le DOM complet) s'écrit <script defer src="app.js"></script>, et il peut aller dans <head>, comme le <link> du CSS.

// ---- index.html (chargé dans head avec defer) ----
// <head>
//   <link rel="stylesheet" href="style.css">
//   <script defer src="app.js"></script>       ← la récupération commence, l'analyse continue
// </head>
// <body>
//   <p id="total">Calcul en cours…</p>          ← app.js s'exécute une fois tout lu jusqu'à </html>
// </body>

// ---- app.js (les 2 mêmes lignes qu'avant, plus 1 pour vérifier) ----
const total = document.querySelector("#total");   // l'élément p
total.textContent = "3 300 yens";                 // le texte affiché change
console.log(total.textContent);                   // 3 300 yens
defer récupère en parallèle et exécute à la fin
<script defersrc="app.js">Lit la suite etajoute body au DOMLit toutjusqu'à </html>DOM completCommence àrécupérer app.jsRécupération encours, l'analysecontinueRécupéré, maispas encore exécutéExécute app.js,trouve #total
app.js est récupéré pendant que l'analyse continue, et même si la récupération se termine avant, l'exécution attend. Il ne s'exécute qu'une fois le DOM complet.

Quand plusieurs scripts ont defer, ils s'exécutent dans l'ordre où ils apparaissent dans le HTML, et non dans l'ordre où leur récupération se termine. Le tableau ci-dessous indique, pour chaque façon d'écrire le script, quand app.js s'exécute et si #total est trouvé.

Balise scriptExécution d'app.js#total
<script src="app.js"> dans headTout de suite, analyse suspendueIntrouvable (null)
<script src="app.js"> avant </body>Après la lecture du pTrouvé
<script defer src="app.js"> dans headUne fois le DOM completTrouvé
Script inline avec defer (head)Tout de suite (defer ignoré sans src)Introuvable (null)
<script type="module" src="app.js"> dans headAprès le DOM, même sans deferTrouvé (sauf en fichier local)

Trouver la ligne fautive et inspecter le DOM — DevTools

Dans la version sans defer, app.js levait une TypeError, mais rien ne l'indiquait sur la page. Sans savoir dans quel fichier et à quelle ligne l'erreur s'est produite, tu ne peux pas trouver pourquoi la page reste bloquée sur « Calcul en cours… ».

Les DevTools (les outils de développement intégrés au navigateur, qui servent à inspecter le DOM et les erreurs d'une page) ont un onglet Console, qui s'ouvre dans Chrome avec Ctrl + Maj + J sous Windows ou Cmd + Option + J sur Mac. On y trouve la sortie de console.log, ainsi que chaque erreur avec le nom du fichier et le numéro de ligne où elle a été levée.

// ---- Erreur affichée dans l'onglet Console (clique sur app.js:2 à droite pour ouvrir la ligne) ----
// Uncaught TypeError: Cannot set properties of null (setting 'textContent')
//     at app.js:2:19       ← levée à la ligne 2, colonne 19

// ---- Une fois la page chargée, tape ceci à l'invite > de la Console, puis appuie sur Entrée ----
document.querySelector("#total");                 // <p id="total">Calcul en cours…</p>
document.querySelector("#total").textContent;     // 'Calcul en cours…'

// ---- Ligne à ajouter après la ligne 1 d'app.js (à ne pas taper dans la Console) ----
console.log("total à l'exécution :", total);      // total à l'exécution : null
La même expression renvoie le p après le chargement
app.js s'exécutependant lechargementAnalyse arrêtéedans headquerySelectorrenvoie nullTypeErrorà la ligne 2Tapé dans laConsole aprèsle chargementAnalyse finie,DOM completquerySelectorrenvoie le pSon texte vaut :« Calculen cours… »
L'expression qui renvoyait null dans app.js renvoie l'élément p dans la Console. Ce qui marche dans la Console ne marche pas forcément dans app.js.

Une expression tapée dans la Console s'exécute sur le DOM tel qu'il est à cet instant. Pour vérifier une valeur à l'intérieur d'app.js, ajoute un console.log avant la ligne suspecte : la valeur au moment de l'exécution s'affichera dans l'onglet Console.

Vérifier les résultats sous forme de texte — innerHTML et outerHTML

Dans les exercices des articles suivants, tu vas chercher, modifier et ajouter des éléments. Mais la console des exercices n'a pas d'écran, et jusqu'au prochain rechargement, tous les exercices partagent une seule page invisible : chaque exécution commence donc avec les éléments laissés par la précédente.

Affecter une valeur à innerHTML (une propriété qui lit et écrit le contenu d'un élément sous forme de chaîne HTML) remplace tout son contenu. Chaque exercice commence par une affectation à l'innerHTML de document.body (l'élément body) pour effacer les éléments précédents, et tu vérifies le résultat en affichant outerHTML (une chaîne HTML qui inclut les balises de l'élément lui-même).

// ① Remplacer tout le contenu de body (les éléments restés de l'exécution précédente disparaissent ici)
document.body.innerHTML = `<p id="total">Calcul en cours…</p>`;

// ② Trouver l'élément et modifier son texte
const total = document.querySelector("#total");
total.textContent = "3 300 yens";

// ③ La page n'est pas visible : afficher les résultats sous forme de chaînes
console.log(total.textContent);          // 3 300 yens
console.log(total.outerHTML);            // <p id="total">3 300 yens</p>
console.log(document.body.innerHTML);    // <p id="total">3 300 yens</p>
La page invisible des exercices
Page de l'article — la console des exercices
  • Seule la sortie de console.log revient, sous forme de texte
Page invisible — une seule pour tous les exercices
  • Contenu de document.body — conservé jusqu'à l'exécution suivante
1re exécution
  • const total — utilisable uniquement dans cette exécution
  • Affecte un p à document.body.innerHTML
2e exécution
  • const total — une nouvelle variable, distincte de la précédente
  • L'affectation du début efface le p de la 1re exécution
Le contenu de body reste en place jusqu'à l'exécution suivante. L'affectation à innerHTML au début efface les éléments restants.

Le code de la console s'exécute à chaque fois dans une nouvelle fonction : const total est donc une variable distincte de celle de l'exécution précédente, et tu n'obtiens pas d'erreur de redéclaration. Il s'exécute aussi après la fin du chargement de la page, donc tu n'as pas à te soucier de defer.

Afficher un élément directement donne {}

La console des exercices n'a pas de page à montrer : elle convertit la sortie en texte avant de la renvoyer. Si tu passes un élément tel quel à console.log, il s'affiche sous la forme {}, sans rien de visible à l'intérieur, et tu ne peux pas savoir si ta modification a marché. Pour vérifier un élément, affiche son outerHTML ou son textContent.

QUIZ

Vérification des connaissances

Répondez à chaque question une par une.

Question 1Dans un script chargé dans head sans defer, que renvoie document.querySelector("#total") ?

Question 2Avec <script defer src="app.js">, quand app.js s'exécute-t-il ?

Question 3Si tu exécutes deux fois document.body.innerHTML = '<p>Calcul en cours…</p>';, combien y a-t-il d'éléments p ?