Développement Web

Fonctions, portee et closures en JavaScript

13 min de lecture

📍 Le guide du parcours : JavaScript moderne : le guide complet

Deuxième tutoriel de la série JavaScript moderne. Il suppose acquise la notion de valeurs et de types vue précédemment.

Introduction

Comment générer des identifiants uniques pour vos tâches sans poser une variable globale que n’importe quel bout de code peut modifier par erreur ? La réponse tient en un mot : les closures. C’est l’une des idées les plus puissantes de JavaScript, et l’une des plus mal comprises. Dans ce tutoriel, vous allez d’abord clarifier les trois façons d’écrire une fonction et la portée des variables, puis vous servir des closures pour encapsuler un état privé. À la fin, vous saurez écrire des fonctions claires, comprendre exactement quelle variable est visible où, et créer un générateur d’identifiants infalsifiable pour CarnetTâches.

🎯 Ce que vous allez apprendre

  • Écrire une fonction de trois manières (déclaration, expression, fonction fléchée) et choisir la bonne.
  • Raisonner sur la portée lexicale : quelle variable est accessible depuis où.
  • Comprendre et construire une closure, et nommer concrètement à quoi elle sert.
  • Utiliser les fonctions d’ordre supérieur (map, filter, reduce) pour transformer une liste de tâches.
  • Implémenter un debounce, exemple canonique de closure utile au quotidien.

🛠️ Ce que vous allez construire

Vous allez écrire la couche « logique » de CarnetTâches : une fabrique creerTache() qui produit des objets tâche bien formés avec un identifiant unique garanti par une closure, un jeu de filtres (tachesActives, parPriorite) bâtis sur les fonctions d’ordre supérieur, et une fonction debounce() réutilisable pour limiter la fréquence d’une recherche.

Prérequis

  • Un navigateur récent et sa console (F12). Aucune installation.
  • Avoir compris les types et la coercition. Test express : si vous savez ce que renvoie typeof [] et pourquoi, vous êtes prêt ; sinon, lisez d’abord Valeurs, types et coercition.
  • ⏱️ Temps estimé : ~40 minutes.

Étape 1 — Trois façons d’écrire une fonction

JavaScript propose plusieurs syntaxes pour définir une fonction, et le débutant les mélange souvent sans saisir leurs différences réelles. Les trois principales sont la déclaration, l’expression et la fonction fléchée. Comprendre ce qui les distingue évite des bugs subtils, notamment autour de la remontée (hoisting) et du mot-clé this.

// 1. Déclaration de fonction — remontée : utilisable avant sa définition
function doubler(n) {
  return n * 2;
}

// 2. Expression de fonction — assignée à une variable, pas remontée
const tripler = function (n) {
  return n * 3;
};

// 3. Fonction fléchée — syntaxe concise, pas de "this" propre
const quadrupler = (n) => n * 4;   // return implicite si une seule expression

console.log(doubler(5), tripler(5), quadrupler(5)); // 10 15 20

La déclaration est remontée en haut de sa portée : on peut l’appeler avant la ligne où elle apparaît. L’expression et la fonction fléchée, elles, ne sont disponibles qu’après leur assignation. La fonction fléchée se distingue surtout par un point essentiel : elle n’a pas son propre this, elle hérite de celui du contexte englobant. C’est précisément ce qui la rend pratique pour les rappels, où l’on veut conserver le this extérieur.

Point d’étape — Appelez doubler(5) avant sa déclaration dans un fichier : ça marche. Faites de même avec tripler : vous obtenez une erreur Cannot access 'tripler' before initialization. Cette différence, c’est le hoisting.

Étape 2 — La portée lexicale

La portée détermine quelles variables sont visibles à un endroit donné du code. JavaScript moderne utilise une portée de bloc pour let et const : une variable n’existe qu’entre les accolades où elle est déclarée. Le terme « lexical » signifie que cette visibilité se lit dans le code source, telle qu’elle est écrite, indépendamment de l’endroit d’où la fonction est appelée.

const appli = "CarnetTâches";        // portée globale du module

function afficherTitre() {
  const prefixe = "Bienvenue dans";   // visible seulement dans la fonction
  console.log(prefixe, appli);        // accède à appli (portée englobante)
}

afficherTitre();                      // "Bienvenue dans CarnetTâches"
// console.log(prefixe);              // ReferenceError : prefixe n'existe pas ici

{
  let interne = 42;                   // portée du bloc uniquement
}
// console.log(interne);              // ReferenceError

Une fonction peut accéder aux variables de sa portée et de toutes les portées qui l’englobent, en remontant jusqu’au global — c’est la chaîne de portées. L’inverse est faux : le code extérieur ne voit pas les variables internes. Cette asymétrie est la clé de tout ce qui suit : elle permet à une fonction de garder des données à l’abri du reste du programme.

Étape 3 — La closure, enfin claire

Une closure se produit lorsqu’une fonction continue d’accéder aux variables de la portée où elle a été créée, même après que cette portée a terminé son exécution. Dit autrement : la fonction « emporte avec elle » son environnement de naissance. C’est ce mécanisme qui permet de fabriquer un état privé. Construisons le générateur d’identifiants de CarnetTâches.

function creerCompteur() {
  let prochain = 1;                 // variable privée, inaccessible de l'extérieur
  return function () {
    return prochain++;              // la fonction renvoyée "se souvient" de prochain
  };
}

const idSuivant = creerCompteur();
console.log(idSuivant()); // 1
console.log(idSuivant()); // 2
console.log(idSuivant()); // 3
// Impossible de remettre "prochain" à zéro de l'extérieur : il est encapsulé.

Observez ce qui se passe : creerCompteur() s’est terminé, mais la variable prochain n’a pas disparu, car la fonction renvoyée la référence encore. Chaque appel à idSuivant() lit et incrémente cette même variable, à l’abri du code extérieur. On vient de créer ce qu’aucune autre construction du langage ne permet aussi simplement : une donnée vraiment privée.

Point d’étape — Créez deux compteurs avec creerCompteur() et appelez-les en alternance. Chacun doit avoir son propre prochain indépendant : a() → 1, b() → 1, a() → 2. Si les deux partagent le même compteur, c’est que vous avez réutilisé la même closure.

Étape 4 — La fabrique de tâches

Réunissons la closure et la modélisation vue au tutoriel précédent. La fabrique creerTache() produit un objet tâche complet, avec un identifiant unique tiré du compteur encapsulé et des valeurs par défaut sensées. C’est le point d’entrée unique pour ajouter une tâche : plus personne n’a à se soucier des identifiants.

const creerTache = (function () {
  let prochain = 1;                       // état privé partagé par la fabrique
  return function (titre, options = {}) {
    return {
      id: prochain++,
      titre: titre.trim(),
      fait: options.fait ?? false,        // ?? : valeur par défaut si null/undefined
      priorite: options.priorite ?? 2,
      etiquettes: options.etiquettes ?? [],
      echeance: options.echeance ?? null
    };
  };
})();                                      // appelée immédiatement (IIFE)

const t1 = creerTache("Réviser les closures");
const t2 = creerTache("Lire la doc", { priorite: 1, etiquettes: ["lecture"] });
console.log(t1.id, t2.id);                // 1 2
console.log(t2);                          // { id: 2, titre: "Lire la doc", fait: false, priorite: 1, ... }

Le motif (function () { ... })() est une expression de fonction immédiatement invoquée : on définit une fonction et on l’appelle aussitôt, ce qui crée une portée privée jetable. L’opérateur ?? (coalescence des nuls) fournit une valeur par défaut uniquement si l’option vaut null ou undefined — contrairement à ||, il ne se déclenche pas sur 0 ou "", ce qui évite d’écraser une priorité légitime de 0.

Étape 5 — Les fonctions d’ordre supérieur

Une fonction qui prend une autre fonction en argument, ou qui en renvoie une, est dite d’ordre supérieur. Les méthodes de tableau map, filter et reduce en sont l’incarnation la plus utile : elles transforment une liste sans boucle for explicite, dans un style déclaratif qui se lit comme une phrase. Construisons les filtres de CarnetTâches.

const taches = [
  creerTache("Réviser", { priorite: 1 }),
  creerTache("Courses", { priorite: 3, fait: true }),
  creerTache("Doc", { priorite: 1 })
];

// filter : ne garde que les éléments qui passent le test
const tachesActives = taches.filter(t => !t.fait);
console.log(tachesActives.length);        // 2

// map : transforme chaque élément
const titres = taches.map(t => t.titre);
console.log(titres);                       // ["Réviser", "Courses", "Doc"]

// reduce : agrège en une seule valeur (ici, compter par priorité)
const parPriorite = taches.reduce((acc, t) => {
  acc[t.priorite] = (acc[t.priorite] ?? 0) + 1;
  return acc;
}, {});
console.log(parPriorite);                  // { 1: 2, 3: 1 }

filter renvoie un nouveau tableau des éléments retenus, map applique une transformation à chacun, et reduce replie la liste en une valeur unique en accumulant au fil du parcours. Ces trois méthodes ne modifient jamais le tableau d’origine : elles en produisent un nouveau. Cette immuabilité rend le code prévisible et facile à enchaîner — on peut écrire taches.filter(...).map(...) en une ligne lisible.

Point d’étape — Après ces opérations, vérifiez que taches.length vaut toujours 3. filter et map ne touchent pas l’original ; s’il a changé, c’est que vous avez modifié le tableau au lieu d’en créer un nouveau.

Étape 6 — Le debounce, une closure du quotidien

Imaginez un champ de recherche dans CarnetTâches : à chaque frappe, on voudrait filtrer les tâches, mais déclencher la recherche à chaque lettre serait un gâchis. Le debounce retarde l’exécution jusqu’à ce que l’utilisateur arrête de taper pendant un court délai. C’est l’exemple parfait de closure utile : la fonction renvoyée doit se souvenir d’un identifiant de minuterie entre deux appels.

function debounce(fn, delai = 300) {
  let minuterie;                          // état privé conservé entre les appels
  return function (...args) {
    clearTimeout(minuterie);              // annule la minuterie précédente
    minuterie = setTimeout(() => fn.apply(this, args), delai);
  };
}

const rechercher = debounce((terme) => {
  console.log("Recherche de :", terme);
}, 500);

rechercher("c");      // ces trois appels rapprochés...
rechercher("ca");
rechercher("car");    // ...ne déclenchent qu'UNE recherche, 500 ms après la dernière frappe
// Affichera une seule fois : "Recherche de : car"

La variable minuterie vit dans la closure : chaque nouvel appel annule la minuterie en cours via clearTimeout et en programme une autre. Tant que les appels s’enchaînent, la fonction fn n’est jamais exécutée ; elle ne l’est qu’une fois le silence revenu. Le ...args (paramètre du reste) capture tous les arguments pour les retransmettre intacts. Ce petit utilitaire de huit lignes vous resservira dans presque tous vos projets.

Étape 7 — Vérification finale

Assemblons la couche logique et prouvons qu’elle tient debout : créer des tâches avec des identifiants uniques, les filtrer, puis confirmer que l’état privé du compteur reste inaccessible.

const liste = [
  creerTache("Réviser", { priorite: 1 }),
  creerTache("Courses", { priorite: 3, fait: true }),
  creerTache("Réviser encore", { priorite: 1 })
];

const ids = liste.map(t => t.id);
const tousUniques = new Set(ids).size === ids.length;
console.log("Identifiants uniques :", tousUniques);   // true

const urgentesActives = liste
  .filter(t => !t.fait && t.priorite === 1)
  .map(t => t.titre);
console.log(urgentesActives);                          // ["Réviser", "Réviser encore"]

Vous devez voir true puis la liste des titres urgents et non faits. Les identifiants sont uniques sans qu’aucune variable globale n’ait été exposée, et les filtres composés se lisent d’une traite. La couche logique de CarnetTâches est prête.

🐞 Pièges fréquents

Symptôme / erreur Cause probable Correctif
this vaut undefined dans un rappel Une fonction classique crée son propre this Utiliser une fonction fléchée, qui hérite du this englobant
Tous les éléments d’une boucle partagent la même valeur var n’a pas de portée de bloc dans la boucle Déclarer la variable de boucle avec let
Une valeur par défaut s’applique à tort sur 0 options.x || défaut traite 0 comme faux Utiliser options.x ?? défaut
Le tableau d’origine a été modifié Confusion entre map/filter et une mutation directe Se rappeler que map/filter renvoient un nouveau tableau
ReferenceError ... before initialization Variable const/let utilisée avant sa déclaration (zone morte temporelle) Déclarer avant d’utiliser

🌍 Réalités du terrain

Les fonctions d’ordre supérieur et les closures ne coûtent rien en téléchargement : ce sont des constructions du langage, disponibles partout, hors ligne, sans dépendance. Sur une machine modeste, préférez filter/map/reduce aux boucles manuelles non pas pour la performance — l’écart est négligeable sur des listes de tâches — mais pour la lisibilité, qui réduit le temps de débogage. Le debounce, lui, a un bénéfice direct sur une connexion limitée : en évitant de déclencher une recherche à chaque frappe, il économise des appels réseau inutiles.

✅ Récapitulatif

Vous savez désormais écrire une fonction de trois manières et choisir la bonne selon le hoisting et le this. Vous lisez la portée lexicale d’un coup d’œil et vous comprenez la chaîne de portées. Surtout, vous maîtrisez la closure : vous l’avez utilisée pour encapsuler le compteur d’identifiants de CarnetTâches et pour construire un debounce. Enfin, vous transformez des listes de tâches avec map, filter et reduce dans un style déclaratif et immuable.

🧾 Aide-mémoire

Élément Rôle
function f() {} Déclaration : remontée, utilisable avant sa ligne
const f = () => ... Fonction fléchée : concise, hérite du this englobant
arr.map(fn) Transforme chaque élément, renvoie un nouveau tableau
arr.filter(fn) Garde les éléments qui passent le test
arr.reduce(fn, init) Replie le tableau en une seule valeur
a ?? b Renvoie b si a vaut null ou undefined
...args Paramètre du reste : capture tous les arguments restants

💪 À vous de jouer

Écrivez une fonction d’ordre supérieur memoize(fn) qui met en cache les résultats d’une fonction coûteuse, de sorte qu’un même argument ne soit calculé qu’une fois. Indice : la closure doit conserver un Map des résultats déjà vus.

Voir une solution
function memoize(fn) {
  const cache = new Map();              // état privé conservé par la closure
  return function (arg) {
    if (cache.has(arg)) {
      return cache.get(arg);            // résultat déjà calculé
    }
    const resultat = fn(arg);
    cache.set(arg, resultat);
    return resultat;
  };
}

const carreLent = (n) => { for (let i = 0; i < 1e6; i++); return n * n; };
const carre = memoize(carreLent);
carre(9);   // calculé : 81
carre(9);   // instantané : lu depuis le cache

Tutoriels liés

Pour aller plus loin

FAQ

Quand préférer une fonction fléchée à une fonction classique ?
Pour les rappels courts et chaque fois que vous voulez conserver le this du contexte englobant (rappels d’événements, méthodes de tableau). Évitez-la comme méthode d’objet quand vous avez besoin d’un this propre.

Une closure provoque-t-elle des fuites de mémoire ?
Seulement si vous gardez vivante une closure qui référence de gros objets dont vous n’avez plus besoin. Tant que la fonction est utilisée, son environnement est légitimement conservé. Libérez la référence (par exemple en la mettant à null) quand elle ne sert plus.

Quelle différence entre ?? et || ?
|| renvoie l’opérande de droite dès que celui de gauche est falsy (donc aussi pour 0, "" ou false). ?? ne le fait que pour null et undefined. Pour des valeurs par défaut, ?? est presque toujours le bon choix.

Pourquoi map plutôt qu’une boucle for ?
Pour la lisibilité et l’absence d’effet de bord : map exprime « transformer chaque élément » sans gérer manuellement un index ni un tableau de sortie. Une boucle for reste préférable quand vous devez interrompre le parcours en cours de route.

Partager