Développement web et mobile

Les bases de JavaScript : types, portée, closures, asynchrone et DOM

Débutant9 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

JavaScript récompense un bon modèle mental bien plus qu'une syntaxe apprise par cœur : comment les valeurs sont converties, comment la portée et les closures capturent les variables, ce qui détermine la valeur de this et dans quel ordre l'event loop exécute vos callbacks. Ce guide construit ces modèles, puis les applique aux modules, aux classes, aux promesses, à la gestion des erreurs, au DOM et à fetch, avec la syntaxe moderne sur laquelle vous pouvez compter aujourd'hui.

Ce que vous allez apprendre

  • Distinguer les primitives des objets et prévoir le comportement des comparaisons et de la coercition
  • Maîtriser la portée et éviter les bugs d'état partagé grâce à let, const, aux closures et aux modules
  • Expliquer ce qui fixe la valeur de this et savoir quand une fonction fléchée est le bon choix
  • Transformer des données avec les méthodes des tableaux et des objets plutôt qu'avec des boucles manuelles et des mutations
  • Raisonner sur l'event loop et écrire du code asynchrone avec les promesses et async/await
  • Manipuler le DOM et appeler des API HTTP avec fetch en traitant délibérément les erreurs

JavaScript est un petit langage entouré d'une très large surface. L'essentiel de la confusion qu'il suscite (pourquoi une comparaison a renvoyé un résultat inattendu, pourquoi une variable contenait la mauvaise valeur dans un callback, pourquoi this s'est volatilisé) tient à quatre ou cinq règles de fond, pas à la syntaxe elle-même. Une fois ces règles acquises, le reste du langage, y compris le framework que vous utilisez, devient beaucoup plus facile à comprendre.

Ce guide couvre ce socle. Il suppose que vous savez déjà lire une boucle for et se concentre sur le modèle : comment se comportent les valeurs, comment la portée et les closures les capturent, ce qui détermine this, comment l'event loop ordonnance votre travail et comment la bibliothèque standard attend que vous transformiez les données. Il emploie partout la syntaxe moderne, y compris les fonctionnalités finalisées dans les éditions récentes d'ECMAScript, et se termine par le DOM et les API réseau, où la plupart du code côté navigateur passe son temps.

Les valeurs : sept primitives et tout le reste

Il existe exactement sept types primitifs (string, number, boolean, undefined, null, bigint et symbol) ; tout le reste est un objet, y compris les tableaux, les fonctions, les dates et les expressions régulières. Les primitives sont copiées par valeur et sont immuables ; les objets sont manipulés par référence, si bien que deux variables peuvent désigner la même chose.

let a = { count: 1 };
let b = a;
b.count = 2;
console.log(a.count);   // 2 — same object

let x = "hi";
let y = x;
y += "!";
console.log(x);         // "hi" — strings are immutable

number est un flottant 64 bits : voilà pourquoi 0.1 + 0.2 ne vaut pas 0.3 et pourquoi les entiers au-delà de 2^53 perdent en précision. Utilisez bigint (un littéral suffixé par n) pour les grands entiers et pour l'arithmétique entière sur des montants exprimés en centimes. typeof null renvoie "object", un bug conservé depuis 1995 par souci de compatibilité.

C'est sur la coercition que les débutants se brûlent les doigts. Les opérateurs + et ==, ainsi que le test de véracité, convertissent implicitement les types, selon des règles alambiquées. Deux habitudes éliminent presque tout le risque. D'abord, comparez toujours avec === ; la seule exception à conserver est value == null, un test compact pour « null ou undefined ». Ensuite, retenez exactement la liste des valeurs falsy (false, 0, -0, 0n, "", null, undefined, NaN), car tout le reste, y compris [], {} et "0", est truthy. Quand vous voulez une valeur par défaut uniquement pour null et undefined, utilisez l'opérateur de coalescence des nuls ?? plutôt que ||, qui remplacerait aussi un 0 ou une chaîne vide parfaitement légitimes. La même logique s'applique au chaînage optionnel : user?.address?.city court-circuite vers undefined au lieu de lever une exception.

Bindings, portée et closures

let et const ont une portée de bloc et vivent dans une « temporal dead zone » du début du bloc jusqu'à l'exécution de la déclaration : y accéder trop tôt lève une ReferenceError au lieu de renvoyer discrètement undefined. var a une portée de fonction et est hissé, ce qui est à l'origine d'une longue liste de bugs classiques. Partez de const par défaut ; il empêche la réaffectation du binding, mais ne gèle pas l'objet vers lequel il pointe.

Une closure est une fonction accompagnée de la portée dans laquelle elle a été créée. Comme la fonction interne conserve une référence vivante vers les variables externes (et non une copie), les closures offrent à JavaScript un état privé :

function createCounter(start = 0) {
  let value = start;                       // captured, not copied
  return {
    increment: () => ++value,
    read: () => value,
  };
}

const counter = createCounter(10);
counter.increment();
console.log(counter.read());               // 11

Rien à l'extérieur ne peut atteindre value. Ce motif sous-tend l'état des modules, la mémoïsation, les gestionnaires d'événements qui se souviennent de leur contexte et toutes les fonctions « factory » que vous croiserez. Le piège historique (une boucle qui créait des gestionnaires partageant tous le même var i) disparaît avec let, qui crée un nouveau binding à chaque itération.

Les fonctions, et ce qui détermine this

Les fonctions sont des valeurs : on peut les passer en argument, les renvoyer et les stocker. Le this d'une fonction classique ne dépend pas de l'endroit où elle a été écrite, mais de la manière dont elle est appelée. Appelée sous la forme obj.method(), this vaut obj. Appelée seule, this vaut undefined en mode strict, ce qui est toujours le cas du code des modules. Appelée avec call, apply ou bind, this vaut ce que vous avez fourni. Appelée avec new, this est l'objet tout juste créé.

Les fonctions fléchées n'ont tout simplement pas de this propre ; elles capturent, définitivement, celui de la portée englobante. C'est exactement ce qu'il faut pour les callbacks, et exactement ce qu'il ne faut pas pour les méthodes d'objet qui ont besoin d'un receveur.

const timer = {
  seconds: 0,
  startBroken() {
    setInterval(function () { this.seconds++; }, 1000);   // this is not timer
  },
  startWorking() {
    setInterval(() => { this.seconds++; }, 1000);         // arrow keeps timer
  },
};

Les paramètres par défaut, les paramètres rest (...args) et la syntaxe de décomposition couvrent presque tout ce pour quoi on utilisait l'ancien objet arguments ; contrairement à lui, ils fonctionnent dans les fonctions fléchées et produisent de vrais tableaux.

Objets, déstructuration et mises à jour immuables

Les littéraux d'objet acceptent les propriétés raccourcies, les clés calculées et les méthodes. La déstructuration extrait les valeurs, gère les valeurs par défaut et le renommage, et la décomposition (spread) copie :

const settings = { theme: "dark", locale: "en", retries: 3 };

const { theme, retries = 1, missing = "fallback" } = settings;
const [first, ...rest] = ["a", "b", "c"];

const updated = { ...settings, theme: "light" };          // new object, old untouched

function connect({ host, port = 5432, ssl = true } = {}) { /* ... */ }

La décomposition produit une copie superficielle : les objets imbriqués restent partagés. Pour cloner en profondeur des données simples, structuredClone(value) est désormais intégré et gère les cycles, les Maps, les Sets et les tableaux typés, ce que ne fait pas JSON.parse(JSON.stringify(x)). Préférer la création de nouveaux objets à la mutation en place, c'est ce qui fait fonctionner la détection de changements dans les frameworks modernes, et ce qui rend l'état plus facile à appréhender en général.

Deux types de collections mériteraient d'être davantage utilisés. Map accepte des clés de n'importe quel type, préserve l'ordre d'insertion et expose directement sa taille, ce qui en fait un meilleur choix qu'un objet ordinaire pour un dictionnaire à clés dynamiques. Set offre des tests d'appartenance peu coûteux et la déduplication : [...new Set(items)] est la façon idiomatique d'obtenir des valeurs uniques.

Les tableaux et les méthodes qui remplacent les boucles

Les méthodes des tableaux expriment l'intention, s'enchaînent proprement et évitent les erreurs de décalage d'indice.

MéthodeRenvoieUsage typique
mapnouveau tableau, même longueurTransformer chaque élément
filternouveau tableau, plus courtGarder les éléments qui correspondent
reduceune seule valeurRéduire en un total, un objet ou un regroupement
find / findIndexélément ou indicePremière correspondance
some / everybooléenTests d'existence et d'universalité
flatMapnouveau tableau aplatiTransformer puis aplatir d'un niveau
sortle même tableau, modifiéTrier : passez un comparateur
toSorted / toReversed / withnouveau tableauÉquivalents sans mutation
const orders = [
  { id: 1, region: "north", total: 120 },
  { id: 2, region: "south", total: 80 },
  { id: 3, region: "north", total: 45 },
];

const northTotal = orders
  .filter((o) => o.region === "north")
  .reduce((sum, o) => sum + o.total, 0);              // 165

const byRegion = Object.groupBy(orders, (o) => o.region);
const highestFirst = orders.toSorted((a, b) => b.total - a.total);

sort modifie le tableau et, sans comparateur, compare les éléments comme des chaînes : c'est pour cela que [10, 9, 1].sort() donne [1, 10, 9]. Les méthodes plus récentes toSorted, toReversed, toSpliced et with renvoient des copies, ce qui correspond généralement à ce que vous vouliez. Object.groupBy et Map.groupBy rendent inutile le reduce écrit à la main le plus répandu.

Modules et classes

Les modules ES sont l'unité de code standard. Chaque fichier a sa propre portée, les imports sont résolus statiquement et tout s'exécute en mode strict :

// money.js
export const CURRENCY = "EUR";
export function format(cents) {
  return new Intl.NumberFormat("fr-FR", { style: "currency", currency: CURRENCY })
    .format(cents / 100);
}
export default class Money { /* ... */ }
// checkout.js
import Money, { format, CURRENCY } from "./money.js";
const lazy = await import("./heavy-chart.js");   // dynamic, code-split

Préférez les exports nommés : on les retrouve avec grep, ils se refactorisent facilement et offrent une meilleure autocomplétion qu'un export par défaut dont chaque module importateur réinvente le nom local. L'import() dynamique renvoie une promesse ; c'est le mécanisme sur lequel reposent les routes chargées à la demande.

Les classes sont une syntaxe posée sur la chaîne de prototypes, avec de véritables champs privés préfixés par #, inaccessibles depuis l'extérieur :

class Account {
  #balance = 0;
  static from(cents) { return new Account(cents); }
  constructor(cents = 0) { this.#balance = cents; }
  get balance() { return this.#balance; }
  deposit(cents) {
    if (cents <= 0) throw new RangeError("deposit must be positive");
    this.#balance += cents;
    return this;
  }
}

La composition à base de fonctions et d'objets simples reste une alternative tout à fait valable ; les classes se justifient quand l'identité et un état mutable encapsulé comptent.

L'event loop, les promesses et async/await

JavaScript exécute votre code sur un seul thread doté d'une file d'attente. Le travail synchrone s'exécute jusqu'au bout ; tout ce qui est asynchrone (un timer, une réponse réseau, un clic) est planifié et traité ensuite. Les callbacks de promesses vont dans la file des microtâches, vidée entièrement après la tâche en cours et avant le timer ou l'étape de rendu suivants : un .then() s'exécute donc toujours avant un setTimeout(..., 0) enregistré plus tôt. Bloquer le thread avec une longue boucle fige le rendu et les saisies ; déportez ce travail dans un Web Worker.

Une promesse représente une valeur en attente (pending), tenue (fulfilled) ou rompue (rejected). async/await est une syntaxe posée sur les promesses : une fonction async renvoie toujours une promesse, et await la suspend jusqu'à ce que la promesse attendue soit réglée.

async function loadDashboard(userId) {
  // Independent work — start both, then wait once.
  const [profile, orders] = await Promise.all([
    fetchJson(`/api/users/${userId}`),
    fetchJson(`/api/users/${userId}/orders`),
  ]);
  return { profile, orders };
}

Les combinateurs comptent : Promise.all est rejetée dès qu'une de ses entrées l'est, Promise.allSettled attend toutes les entrées et rend compte de chaque résultat, Promise.any se résout avec le premier succès, et Promise.race se règle avec la première qui se termine, ce qui sert de base à un timeout. Faire un await dans une boucle sérialise des requêtes qui auraient pu s'exécuter en parallèle ; c'est le ralentissement évitable le plus courant du code asynchrone.

Dans une fonction async, les erreurs sont des exceptions ordinaires : try/catch/finally fonctionne, et un throw s'y transforme en rejet. Levez des objets Error plutôt que des chaînes pour conserver une stack trace, sous-classez Error pour les échecs métier, et utilisez l'option cause pour préserver l'erreur d'origine quand vous l'enveloppez. Un rejet non traité est un vrai défaut : ajoutez un catch ou laissez un gestionnaire de plus haut niveau le journaliser.

Le DOM, les événements et fetch

Le DOM est le modèle objet que le navigateur construit à partir du document. Interrogez-le avec querySelector et querySelectorAll, construisez des nœuds plutôt que de concaténer des chaînes HTML, et gardez en tête qu'affecter à innerHTML des données non fiables, c'est ainsi que naît le cross-site scripting : textContent est le choix sûr par défaut.

const list = document.querySelector("#orders");

const row = document.createElement("li");
row.textContent = `Order ${order.id}`;
row.dataset.orderId = order.id;
list.append(row);

// One delegated listener instead of one per row.
list.addEventListener("click", (event) => {
  const item = event.target.closest("li[data-order-id]");
  if (item) openOrder(item.dataset.orderId);
});

Les événements remontent (bubbling) de la cible vers ses ancêtres, et c'est ce qui rend la délégation possible : attachez un seul gestionnaire à un parent stable, et il continuera de fonctionner pour les lignes ajoutées plus tard. event.preventDefault() empêche l'action par défaut du navigateur ; stopPropagation() interrompt la remontée et doit être utilisé avec parcimonie, car il casse les autres écouteurs. Quand un composant ajoute des écouteurs à des objets à longue durée de vie, conservez le signal d'un AbortController pour pouvoir tous les retirer d'un coup.

fetch renvoie une promesse qui n'est rejetée qu'en cas d'échec réseau : une réponse HTTP 404 ou 500 la résout quand même, ce qui surprend tout le monde au moins une fois :

async function fetchJson(url, options = {}) {
  const response = await fetch(url, {
    headers: { Accept: "application/json" },
    ...options,
  });
  if (!response.ok) {
    throw new Error(`${response.status} ${response.statusText} for ${url}`);
  }
  return response.json();
}

Vérifiez toujours response.ok, et décidez de ce que chaque statut signifie pour l'appelant : la référence des codes de statut HTTP indique lesquels valent la peine d'être retentés. Utilisez AbortController pour annuler les requêtes en cours quand un composant est démonté ou que le contenu d'un champ de recherche change. Quand vous déboguez une charge utile à la main, le formateur JSON et l'encodeur/décodeur d'URL vont plus vite qu'une session dans la console, et les notions vues ici se transposent directement dans un framework : consultez les bases d'Angular pour voir comment ces mêmes primitives réapparaissent derrière un modèle de composants.

Points clés à retenir

  • Les sept primitives sont copiées par valeur, tout le reste est une référence ; utilisez ===, apprenez par cœur la liste des valeurs falsy, et préférez ?? et ?. au code défensif répétitif.
  • const et let ont une portée de bloc ; les closures capturent des bindings vivants, et c'est ainsi que le langage fournit un état privé.
  • Le this d'une fonction classique dépend de l'appel, alors qu'une fonction fléchée le capture lexicalement : cette seule règle explique la plupart des surprises avec les callbacks.
  • Transformez les données avec map, filter, reduce et les méthodes récentes sans mutation toSorted/with, et copiez avec la décomposition en gardant à l'esprit qu'elle est superficielle.
  • L'event loop vide les microtâches avant les timers ; utilisez async/await pour la logique séquentielle et Promise.all pour les tâches indépendantes plutôt que d'enchaîner des await dans une boucle.
  • Dans le navigateur, préférez textContent à innerHTML, déléguez les événements à un ancêtre stable et vérifiez toujours response.ok, car fetch ne rejette pas sa promesse sur une erreur HTTP.

Sources et lectures complémentaires

Questions fréquentes

Faut-il encore utiliser var ?
Dans du code neuf, non. var a une portée de fonction et est hissé sous forme de binding déjà initialisé : il fuit hors des blocs et accepte sans broncher d'être redéclaré. let et const ont une portée de bloc et restent dans la temporal dead zone jusqu'à l'exécution de leur déclaration, ce qui transforme toute une famille de bugs subtils en erreur immédiate. Partez de const, passez à let seulement si vous réaffectez vraiment, et gardez var pour la lecture du vieux code.
Quelle est la différence entre == et === ?
L'égalité stricte compare le type et la valeur, sans aucune conversion. L'égalité faible applique d'abord l'algorithme de comparaison abstraite : une chaîne peut alors être égale à un nombre, et null est égal à undefined. Les règles de conversion sont si nombreuses que rares sont ceux qui s'en souviennent correctement sous pression ; utilisez donc === partout. Le seul idiome qui mérite d'être conservé est value == null, qui teste null ou undefined en une seule comparaison.
Pourquoi this devient-il undefined dans mon callback ?
Dans une fonction classique, this dépend de la manière dont la fonction est appelée, et non de l'endroit où elle a été écrite. Passer une méthode en callback la détache de son receveur : this vaut alors undefined en mode strict et dans le code des modules. Écrivez le callback sous forme de fonction fléchée, qui capture this lexicalement depuis la portée englobante, ou liez explicitement la méthode avec bind au moment de la transmettre.
Quand préférer async/await à .then() ?
Préférez async/await pour la logique séquentielle : le code se lit de haut en bas, fonctionne avec un try/catch ordinaire et conserve des stack traces parlantes. Tournez-vous vers les combinateurs de promesses quand le flux n'est pas séquentiel : Promise.all pour exécuter en parallèle des tâches indépendantes, Promise.allSettled quand il vous faut chaque résultat, échecs compris, Promise.race pour les timeouts. Faire un await dans une boucle qui aurait pu être parallèle est une erreur de performance courante.