← Retour aux articles
Core JavaScriptV8Performance

Moteur V8 : comment JavaScript tourne réellement dans votre navigateur

· 7 min de lecture

JavaScript est souvent qualifié de langage interprété. Ce n’est plus vrai depuis plus d’une décennie. V8, le moteur derrière Chrome, Edge, Node.js et Deno, compile JavaScript en code machine avant de l’exécuter. Il parse le code source, génère du bytecode via un interpréteur appelé Ignition, puis identifie les chemins de code chauds et les recompile en code machine optimisé via un compilateur appelé TurboFan. Comprendre ce pipeline explique pourquoi certains patterns JavaScript sont radicalement plus rapides que d’autres.

Le pipeline d’exécution

Quand V8 reçoit du code JavaScript, il ne l’exécute pas directement. Le code passe par un pipeline multi-étapes où chaque étape le rend plus rapide.

Code Source → Parser → AST → Ignition (bytecode) → TurboFan (code machine)

Le parser lit la source et produit un Arbre Syntaxique Abstrait. Ignition parcourt l’AST et génère du bytecode compact qui s’exécute immédiatement. C’est rapide à produire mais pas rapide à exécuter. Pendant que le code tourne, V8 profile quelles fonctions sont appelées fréquemment. TurboFan prend ces données de profilage et compile les fonctions chaudes en code machine hautement optimisé qui tourne à une vitesse quasi native.

Cette approche à deux niveaux est délibérée. Ignition démarre l’application rapidement. TurboFan la rend rapide dans le temps en concentrant l’effort d’optimisation là où ça compte le plus.

Ignition : un démarrage rapide avec le bytecode

Ignition compile JavaScript en un format bytecode compact. Chaque instruction fait typiquement un ou deux octets, rendant la compilation initiale rapide et économe en mémoire.

function add(a, b) {
  return a + b;
}

V8 ne génère pas de code machine pour chaque fonction dès le départ. Les fonctions qui ne tournent qu’une ou deux fois restent en bytecode, ce qui est parfaitement suffisant pour du code qui n’est pas critique en performance. Seules les fonctions qui se révèlent chaudes sont promues vers TurboFan.

En exécutant le bytecode, Ignition collecte aussi des informations de types. À chaque appel de add, V8 enregistre quels types étaient a et b. S’ils sont toujours des nombres, TurboFan peut ensuite générer du code machine spécialisé qui saute complètement la vérification de types.

TurboFan : rendre le code chaud rapide

TurboFan est le compilateur optimisant de V8. Il utilise les informations de types d’Ignition pour produire du code machine qui suppose que les types observés jusqu’ici continueront d’être les mêmes.

function sum(arr) {
  let total = 0;
  for (let i = 0; i < arr.length; i++) {
    total += arr[i];
  }
  return total;
}

// Appelé 10 000 fois avec des tableaux de nombres
for (let i = 0; i < 10000; i++) {
  sum([1, 2, 3, 4, 5]);
}

Après suffisamment d’itérations, TurboFan compile sum en code machine optimisé pour les tableaux de nombres. Addition d’entiers native, moins de vérifications de sécurité, pas de surcoût lié aux types dynamiques. Le résultat tourne des ordres de grandeur plus vite que la version bytecode.

C’est de l’optimisation spéculative : V8 parie que les types qu’il a vus vont continuer à apparaître. Quand ce pari est gagnant, le code tourne à une vitesse quasi native.

Désoptimisation : quand les types changent

Quand les hypothèses de types de TurboFan sont violées, V8 désoptimise la fonction. Il jette le code machine et retombe sur le bytecode d’Ignition.

function process(value) {
  return value + 1;
}

// V8 optimise pour les nombres
for (let i = 0; i < 10000; i++) {
  process(42);
}

// Le type change : désoptimisation déclenchée
process('hello');

Les 10 000 premiers appels entraînent TurboFan à supposer que value est toujours un nombre. Quand "hello" arrive, le code optimisé ne peut pas le gérer. V8 retombe sur le bytecode et réapprend les types. Si la fonction se stabilise à nouveau avec des types cohérents, TurboFan la réoptimisera.

La conclusion est simple : les fonctions qui reçoivent toujours les mêmes types tournent plus vite que celles qui reçoivent des types mixtes. V8 récompense la cohérence.

Formes d’objets et accès aux propriétés

Les objets JavaScript n’ont pas de structure fixe. Les propriétés peuvent être ajoutées ou supprimées à tout moment. Mais V8 optimise pour le cas courant où des objets de même forme sont créés de manière répétée.

// Même forme : V8 optimise l'accès aux propriétés
const a = { name: 'Alice', age: 30 };
const b = { name: 'Bob', age: 25 };

// Forme différente : plus lent
const c = { name: 'Charlie', age: 35 };
c.email = 'charlie@test.com';

Quand a et b ont les mêmes propriétés dans le même ordre, V8 leur assigne la même forme interne. L’accès aux propriétés devient une lecture mémoire à offset fixe, aussi rapide que l’accès à un champ en C. Ajouter email à c lui donne une forme différente, et l’accès aux propriétés retombe sur un chemin plus lent.

C’est pourquoi initialiser toutes les propriétés dès le départ (dans le constructeur ou le littéral d’objet) produit du code plus rapide que d’ajouter des propriétés conditionnellement après la création.

Gestion de la mémoire

V8 utilise un ramasse-miettes générationnel. La plupart des objets meurent jeunes : les variables locales d’une fonction, les tableaux temporaires et les valeurs intermédiaires deviennent tous des déchets dès que la fonction retourne. V8 exploite ce pattern en divisant la mémoire en deux régions.

La jeune génération est petite et collectée fréquemment. Comme la majorité de son contenu est déjà mort, la collecte est rapide. Les objets qui survivent à plusieurs collectes sont promus vers la vieille génération, qui est plus grande et collectée moins souvent.

// Crée du déchet temporaire : pas de problème, le GC gère efficacement
function processItems(items) {
  return items.filter((item) => item.active).map((item) => ({ id: item.id, label: item.name }));
}

// Garde des références indéfiniment : fuite mémoire
const cache = new Map();
function getUser(id) {
  if (!cache.has(id)) {
    cache.set(id, fetchUser(id));
  }
  return cache.get(id);
}

La chaîne filter + map crée des tableaux intermédiaires qui meurent immédiatement. Le GC gère ça efficacement. Le cache Map sans limite grandit indéfiniment parce que rien ne supprime les entrées. C’est le pattern de fuite mémoire le plus courant en Node.js : des caches sans éviction.

L’event loop

V8 compile et exécute JavaScript, mais il ne gère pas l’I/O, les timers ou les opérations asynchrones. Ça appartient à l’environnement d’hébergement (Chrome, Node.js). L’event loop est ce qui les connecte.

console.log('start');

setTimeout(() => {
  console.log('timeout');
}, 0);

Promise.resolve().then(() => {
  console.log('microtask');
});

console.log('end');

// Sortie : start, end, microtask, timeout

V8 exécute le code synchrone d’abord. setTimeout planifie un callback dans la file des tâches (gérée par l’environnement). Promise.then planifie une microtâche (gérée par V8). Les microtâches s’exécutent toujours avant la prochaine tâche, c’est pourquoi microtask s’affiche avant timeout même si setTimeout a été appelé en premier.

Comprendre cette distinction compte pour la performance. Une chaîne récursive de microtâches bloque l’event loop autant que du code synchrone, parce que V8 vide entièrement la file des microtâches avant de rendre le contrôle à l’environnement.

Conclusion

V8 transforme JavaScript d’un langage de script en quelque chose qui rivalise avec les langages compilés sur les tâches intensives en calcul. Ignition fait tourner le code rapidement. TurboFan rend les chemins chauds plus rapides. Le garbage collector gère efficacement les allocations éphémères. Savoir comment ce pipeline fonctionne ne change pas la façon dont on écrit le code au quotidien, mais quand la performance compte, ça dit où chercher : garder les types cohérents, initialiser les objets avec toutes leurs propriétés, et laisser le garbage collector faire son travail en évitant les caches sans limite.