Expliquer le JavaScript des navigateurs lors d’entretiens : files d’attente, tas mémoire, fuites de mémoire, closures
Une présentation structurée du moteur de navigation pour la préparation aux entretiens : pile d’appels, files d’attente de tâches et de microtâches, mémoire dynamique et collecte des déchets, fuites mémoire, délégation, prototypes et closures.
Sources: #1 FrontEnd Concepts For Interviews — Javascript Fundament
Les entretiens front-end récompensent rarement les réponses par cœur. Les interviewers cherchent à savoir si vous pouvez expliquer ce que fait le moteur avec votre code : où attend une fonction de rappel, pourquoi un objet survit à sa fonction, pourquoi un seul écouteur sur un conteneur peut remplacer des centaines d’autres. Ce guide aborde ces fondamentaux dans l’ordre où ils s’enchaînent, afin que vous puissiez raisonner face à une question inconnue plutôt que de réciter une réponse. Un bon auto-test : après chaque section, expliquez l’idée à voix haute comme si vous le faisiez à un collègue qui n’en a jamais entendu parler.
La pile d’appels : où s’exécute votre code
Tout moteur JavaScript exécute du code sur une pile d’appels, une structure du type dernier entré, premier sorti que l’on peut imaginer comme un tableau où seules les opérations d’ajout en fin de liste et de suppression du début sont possibles. L’appel d’une fonction ajoute un cadre représentant son contexte d’exécution ; le retour en supprime un. Il n’y a qu’une seule telle pile sur le thread principal, de sorte que tant qu’un cadre est en exécution, rien d’autre ne peut l’être. Une boucle synchrone longue bloque le rendu et les entrées jusqu’à son achèvement.
Pourquoi une requête réseau ne bloque pas la page
Puisque seule une opération peut s’exécuter à la fois, pourquoi la page ne stagne-t-elle pas en attendant une réponse du serveur ? Parce que ce n’est pas JavaScript qui attend. Imaginez un écran qui traite un grand ensemble de données avant d’appeler plusieurs API successivement. L’appel à fetch() transfère cette tâche à une API Web fournie par le navigateur, qui gère les communications réseau en dehors de la pile d’appels. Votre fonction renvoie immédiatement une promesse en attente, permettant ainsi à l’utilisateur de continuer à interagir.
Lorsque la réponse arrive, la promesse est résolue et ses callbacks .then() sont planifiés en tant que microtâches, qui s’exécutent une fois la pile vide. Stricto sensu, l’achèvement de la requête réseau est géré par le mécanisme de tâches du navigateur, et ce qui entre dans la file des microtâches, ce sont les réactions de la promesse. Le point important à retenir est que votre continuation est mise en file, et non exécutée directement.
File de tâches versus file de microtâches
Les compteurs temporels suivent une voie différente : un callback de setTimeout() est placé dans la file de tâches, également appelée file de macrotâches. La règle du cycle d’événements est que, après l’achèvement de la tâche en cours et le vide de la pile, il épuise toute la file des microtâches avant de sélectionner la tâche suivante. Cette fonction planifie une tâche de chaque type ; les numéros indiqués dans les messages révèlent l’ordre d’exécution.
function triggerBothQueues() {
console.log("1. Synchronous: Code starts on Call Stack");
// Enqueue a Macrotask (Task Queue)
setTimeout(() => {
console.log("4. Task Queue (Macrotask): setTimeout callback executed");
}, 0);
// Enqueue a Microtask (Microtask Queue)
Promise.resolve().then(() => {
console.log("3. Microtask Queue: Promise.then callback executed");
});
console.log("2. Synchronous: Code ends on Call Stack");
}
triggerBothQueues();
Les deux lignes synchrones s’affichent en premier, suivies du callback de la promesse, puis du timer. Un délai de 0 signifie « mettre en file d’attente dès que possible », et non « exécuter immédiatement » ; le microtask l’emporte néanmoins. Une question fréquente : un microtask qui planifie un autre microtask rejoint la même file d’attente et s’exécute avant tout timer, c’est pourquoi une chaîne infinie de microtasks peut entraver le rendu.
Le heap : où vivent les objets
La pile contient les contextes d’exécution ; le tas contient les données auxquelles ils font référence. Les objets, les tableaux, les fonctions et tout ce qui est géré par référence se trouvent sur le tas, tandis que les primitives locales telles que les nombres et les booléens sont, conceptuellement, stockées avec le contexte d’exécution de la fonction. Précisez lors d’un entretien qu’il s’agit d’une simplification : les moteurs allouent généralement les chaînes de caractères sur le tas et peuvent les partager, et une primitive stockée en tant que propriété d’un objet se trouve sur le tas avec cet objet. Ce modèle sert d’aide mentale, et non de spécification.
Collecte des déchets et atteignabilité
Le tas n’est pas infini. Un collecteur de déchets en arrière-plan récupère la mémoire en s’appuyant sur un principe : l’atteignabilité :
- Les racines sont par définition atteignables : les variables globales, ainsi que les paramètres et les variables locales des fonctions actuellement présentes dans la pile.
Tout ce qui ne peut pas être atteint depuis aucune racine est considéré comme du déchet. Le collecteur ne se soucie pas de savoir si vous avez encore l’intention d’utiliser un objet ; s’il existe une référence accessible, celui-ci reste en mémoire. C’est ainsi que se produisent les fuites de mémoire. Pour une analyse approfondie axée sur les outils, consultez la méthode pour détecter les fuites de mémoire JavaScript grâce à la notion d’accessibilité et aux retainers.
Deux manières classiques de provoquer des fuites de mémoire
Un chronomètre qui ne s’arrête jamais
Dans ce suivi de tableau de bord, un grand tableau est créé à l’intérieur d’une fonction, et une fonction de rappel intervalle lit sa longueur toutes les secondes.
function startDashboardTracker() {
const hugeDataPayload = new Array(1000000).fill("data");
// Timer is launched
setInterval(() => {
// Because hugeDataPayload is referenced here,
// it stays trapped in memory forever!
console.log("Tracker pinging... Payload length:", hugeDataPayload.length);
}, 1000);
}
startDashboardTracker();
// Even after startDashboardTracker finishes executing and leaves the Call Stack,
// hugeDataPayload CANNOT be garbage collected because the timer's closure holds a reference to it.
Lorsque startDashboardTracker retourne, on pourrait penser que le tableau devient inutilisable. Ce n’est pas le cas : le navigateur conserve l’intervalle actif, cet intervalle garde sa fonction de rappel, et la fermeture de cette fonction retient hugeDataPayload. Conservez l’ID retourné par setInterval() et appelez clearInterval() une fois le travail terminé ou lorsque un composant UI est désinstallé ; rompre ce premier lien libère toute la chaîne.
Un écouteur sur un objet global
Celui-ci est plus subtil car rien ne semble être en cours d’exécution. Un widget de recherche crée un grand index et enregistre un gestionnaire de redimensionnement sur window.
function setupSearchWidget() {
// Heavy data tied to the widget scope
const heavySearchIndex = new Array(1000000).fill("search_index_item");
function handleResize() {
console.log("Window resized. Index size:", heavySearchIndex.length);
}
// Listener attached to the global 'window' object (a Root)
window.addEventListener("resize", handleResize);
}
setupSearchWidget();
// Later, user navigates away and the Search Widget DOM element is deleted.
// BUT 'window' still holds a reference to 'handleResize',
// which holds a reference to 'heavySearchIndex'.
Lorsque l’utilisateur quitte la page et que le DOM du widget est supprimé, window, qui constitue la racine, continue de lister handleResize, ce qui crée une référence vers heavySearchIndex. Appelez removeEventListener lorsque le widget est démonté, en passant la même référence de fonction que celle que vous avez enregistrée ; une nouvelle fonction flèche représente un objet différent et ne supprime rien.
Bubbling des événements et délégation
React et Angular cachent une grande partie de cette gestion. L’une des raisons est la délégation des événements : au lieu d’avoir un écouteur sur chaque nœud, React écoute depuis le conteneur racine et envoie les événements vers vos gestionneurs au fur et à mesure qu’ils remontent, tandis que son système d’événements synthétiques attache et détache ces gestionneurs lorsque les composants sont montés ou démontés.
La délégation repose sur le mécanisme de propagation vers le haut : un clic est envoyé à l’élément cible puis remonte à travers chacun de ses ancêtres. Cette page contient trois boîtes colorées, chacune disposant d’un écouteur de clic.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Event Bubbling Example</title>
<style>
#grandparent {
background-color: lightcoral;
padding: 20px;
}
#parent {
background-color: lightblue;
padding: 20px;
}
#child {
background-color: lightgreen;
padding: 20px;
}
</style>
</head>
<body>
<div id="grandparent">
<h1>Grandparent - only one event listener</h1>
<div id="parent">
Parent
<div id="child">
Child
</div>
</div>
</div>
<script>
document.getElementById('grandparent').addEventListener('click', function(event) {
console.log('Grandparent clicked!');
console.log('target: ', event.target.getAttribute("id"));
// if (event.target.getAttribute("id") === 'child') {
// //
// }
});
document.getElementById('parent').addEventListener('click', function(event) {
console.log('Parent clicked!');
// event.stopPropagation();
});
document.getElementById('child').addEventListener('click', function(event) {
console.log('Child clicked!');
// event.stopPropagation();
});
</script>
</body>
</html>
En cliquant sur la boîte la plus intérieure, on enregistre l’élément enfant, puis le parent, et enfin le grand-parent. Le grand-parent lit event.target pour savoir quel élément a réellement été cliqué, et l’if commenté indique comment cet unique écouteur pourrait être conditionné en fonction de l’ID, permettant ainsi aux éléments enfants d’en être dépourvus. C’est là une délégation en miniature : un seul écouteur placé haut dans la hiérarchie couvre tous les descendants, y compris ceux ajoutés ultérieurement, tout en consommant une fraction minime de mémoire.
Les appels commentés de event.stopPropagation() montrent un contrôle inverse : activés au niveau de l’élément enfant, ils arrêtent l’événement avant qu’il n’atteigne les éléments parents (voir la documentation de stopPropagation). Utilisez-le avec modération, car il empêche également les gestionnaires délégués situés plus haut dans la hiérarchie, y compris l’écouteur racine d’un framework, de fonctionner. Il est utile d’expérimenter directement avec ce fichier ; la méthode simple addEventListener devrait vous sembler familière avant même d’aborder les abstractions des frameworks.
Programmation fonctionnelle en bref
La programmation fonctionnelle (PF) est souvent représentée comme une chaîne de montage composée d’machines isolées : chacune prend en entrée des données, produit une sortie et n’affecte rien d’autre. Alors que le code orienté objet a tendance à modifier l’état des objets, la PF évite toute mutation et repose sur cinq principes :
- Fonctions pures : la sortie dépend uniquement de l’entrée, sans aucun effet secondaire externe.
map et filter, plutôt que de gérer des compteurs et un état.Classe et prototypes
Dès ES2022, les classes prennent en charge des champs privés réels grâce au préfixe #. Ici, #balance n’est accessible qu’à l’intérieur du corps de la classe.
// 1. PURE JAVASCRIPT (Native ES2022+)
class BankAccount {
#balance = 0; // Native JS private field (using #)
constructor(owner, initialBalance) {
this.owner = owner;
this.#balance = initialBalance;
}
deposit(amount) {
this.#balance += amount;
}
}
const account = new BankAccount("Alex", 1000);
// account.#balance; ❌ Uncaught SyntaxError (Hard runtime privacy)
Lire account.#balance depuis l’extérieur provoque une SyntaxError ; ainsi, la confidentialité est garantie par le langage, et non par une convention.
Au fond, JavaScript utilise toujours l’héritage par prototype : les objets délèguent la recherche de propriétés à un prototype. Avant ES6, des structures similaires aux classes étaient construites manuellement à l’aide de fonctions constructeurs, comme dans cette version du compte avec une sous-classe pour les économies.
// 1. BASE CONSTRUCTOR FUNCTION (Parent Class)
function BankAccount(owner, initialBalance) {
this.owner = owner; // Public property
// PRIVATE VARIABLE (Data Privacy via Closure)
// Lives in function scope, completely hidden from the outside world!
var balance = initialBalance;
// Public method privileged to access the private 'balance' variable
this.deposit = function (amount) {
balance += amount;
this._logTransaction("Deposited quot; + amount + ". New Balance: quot; + balance);
};
}
// PROTECTED / SHARED METHODS (Prototypal Inheritance)
// Attached to the prototype so methods aren't re-created for every single instance
BankAccount.prototype._logTransaction = function (action) {
console.log("[" + this.owner + "]: " + action);
};
// 2. DERIVED CONSTRUCTOR FUNCTION (Child Class)
function SavingsAccount(owner, initialBalance, interestRate) {
// Call Parent Constructor with 'this' context (Super Call)
BankAccount.call(this, owner, initialBalance);
this.interestRate = interestRate;
}
// Inherit parent prototype methods on the child
SavingsAccount.prototype = Object.create(BankAccount.prototype);
SavingsAccount.prototype.constructor = SavingsAccount; // Reset constructor reference
// Child Public Method
SavingsAccount.prototype.addInterest = function () {
// Calls the inherited prototype method
this._logTransaction("Applied " + this.interestRate + "% interest");
};
// 3. USAGE
var mySavings = new SavingsAccount("Alex", 1000, 5);
mySavings.deposit(200); // -> [Alex]: Deposited $200. New Balance: $1200
mySavings.addInterest(); // -> [Alex]: Applied 5% interest
Remarquez que balance est privé grâce à une fermeture : il se trouve dans le scope du constructeur et seul deposit peut y accéder. _logTransaction se trouve sur le prototype afin que toutes les instances partagent une même fonction ; son underscore signifie simplement, par convention, qu’il s’agit d’une fonction « protégée ». BankAccount.call(this, ...) correspond au super() manuel, et Object.create lie les prototypes avant que la propriété constructor modifiée ne soit restaurée.
Le code front-end moderne privilégie de toute façon la composition à l’héritage. Si vous souhaitez des contrôles OOP plus stricts tels que les modificateurs d’accès et les interfaces, TypeScript est le choix idéal ; cette explication plus détaillée va encore plus loin.
Fermetures
Il y a beaucoup plus à explorer, des APIs du navigateur comme Canvas aux modules, itérateurs et générateurs, mais les fermetures apparaissent dans presque tous les entretiens et vous les avez déjà rencontrées dans les exemples de fuites. Une fermeture est une fonction associée à l’environnement lexical dans lequel elle a été créée. Comme la fonction interne conserve une référence à cet environnement, elle peut toujours lire et mettre à jour les variables de la fonction externe longtemps après que cette dernière ait renvoyé.
function createCounter() {
let count = 0; // Outer variable
return function() {
count++; // Inner function accesses 'count'
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
count persiste entre les appels car la fonction retournée conserve un accès à son environnement, ce qui est directement lié aux règles de collecte des déchets. Chaque appel à createCounter génère un compteur indépendant.
Une fermeture pratique : le debounce
function debounce(func, delay = 300) {
let timer; // 1. The closure variable that holds the timeout reference
return function (...args) {
// 2. If the function is called again before the delay finishes,
// cancel the previous scheduled run
clearTimeout(timer);
// 3. Schedule a new run after the delay
timer = setTimeout(() => {
func.apply(this, args);
}, delay);
};
}
// --- Usage Example ---
function handleSearch(query) {
console.log(`Searching API for: ${query}`);
}
// Wrap our search function in the debounce function
const debouncedSearch = debounce(handleSearch, 500);
// Simulating fast typing into an input field:
debouncedSearch("a"); // Timer starts...
debouncedSearch("ap"); // Cancels "a", resets timer...
debouncedSearch("app"); // Cancels "ap", resets timer...
debouncedSearch("apple");// Cancels "app", resets timer...
// Only "Searching API for: apple" will log to the console
// 500ms after the last keypress!
Le timer en boucle constitue l’état. Chaque appel efface le délai d’attente en cours et planifie un nouveau, de sorte que seul le dernier appel dans une série est exécuté : taper rapidement « a », « ap », « app », « apple » ne génère qu’une recherche pour « apple » 500 ms après le dernier appui sur une touche. func.apply(this, args) transmet à la fois les arguments et le contexte d’appel. Pour les cas de suppression et les situations particulières, consultez comment créer une utilité debounce à partir de zéro.
Points clés
- Une pile d’appels exécute votre code ; les API du navigateur s’occupent de l’attente et retournent les résultats via des files d’attente.
- Tous les microtâches en attente s’exécutent avant la tâche suivante, comme un rappel de timer.
- Le collecteur conserve tout ce qui est accessible depuis une racine, que cela soit nécessaire ou non.