Les Opérations Asynchrones

Présentation

Jusqu'à présent, nous avons écrit du code JavaScript qui s'exécute de manière synchrone où chaque instruction est exécutée une après l'autre, dans l'ordre où elles apparaissent. Cependant, certaines opérations prennent du temps, comme le chargement d'une image, l'attente d'une réponse d'un serveur ou encore l'exécution d'animations.

Si JavaScript attendait systématiquement qu'une opération soit terminée avant de passer à la suivante, l'exécution du programme serait bloquée, empêchant toute autre interaction avec l'utilisateur. C'est pourquoi JavaScript utilise un modèle asynchrone, qui permet de continuer l'exécution du programme tout en attendant que certaines tâches longues soient terminées.

Vous avez déjà rencontré des opérations asynchrones sans forcément vous en rendre compte, notamment avec les gestionnaires d'événements. Par exemple, lorsqu'on associe une fonction de rappel à un clic de souris ou à une saisie clavier, cette fonction n'est exécutée que plus tard, sans interrompre le reste du programme.

Les opérations asynchrones interviennent dans de nombreux contextes où le programme ne peut pas se permettre d'attendre indéfiniment une tâche avant de poursuivre son exécution, notamment :

  • Requêtes réseau : Chargement de données depuis un serveur ou communication avec une API.
  • Chargement de ressources : Téléchargement d'images, de polices ou de scripts externes.
  • Animations et interactions : Gestion des délais, effets visuels et interactions utilisateur.
  • Opérations de fichiers et bases de données : Lecture, écriture ou synchronisation de données.

Les Timers

La méthode setTimeout() est utilisée pour programmer l'exécution d'une fonction ou d'un morceau de code après un certain délai, exprimé en millisecondes. Elle prend une fonction de rappel (callback) en premier argument et en second argument le délai d'attente avant l'exécution de la fonction de rappel.


                    console.log("Texte avant le setTimeout.");

                    // Utilise setTimeout pour définir une fonction de rappel qui sera exécutée 
                    // après un délai de 1000 millisecondes (1 seconde).
                    // Une fois ce délai écoulé, la fonction affiche le message "le délai d'une
                    // seconde est passé." dans la console.
                    setTimeout(() => {
                        console.log("le délai d'une seconde est passé.");
                    }, 1000);

                    console.log("Texte après le setTimeout.");

                    /*
                        Affiche : 
                            Texte avant le setTimeout.
                            Texte après le setTimeout.
                            le délai d'une seconde est passé.
                    */
                

La méthode setInterval() est utilisée pour programmer l'exécution répétée d'une fonction ou d'un morceau de code à des intervalles réguliers, spécifiés en millisecondes. Elle prend une fonction de rappel (callback) en premier argument et en second argument la période d'attente avant chaque exécution de la fonction de rappel.


                    // Initialise la variable n à 1.
                    let n = 1

                    // Utilise setInterval pour définir une fonction de rappel qui sera exécutée
                    // toutes les 1000 millisecondes (1 seconde).
                    // À chaque exécution, la fonction affiche la valeur actuelle de n à l'aide de
                    // "console.log()", puis incrémente n de 1.
                    setInterval(() => {
                        console.log(n++);
                    }, 1000);
                    /*
                        Affiche :
                            1
                            2
                            3
                            4
                            ...
                    */
                

Il est possible d'annuler ces "timers" en utilisant clearTimeout() et clearInterval(). Ces méthodes d'annulation acceptent en argument l'identifiant du timer à annuler. Cet identifiant est généré et renvoyé par les fonctions setTimeout() et setInterval() lorsqu'elles sont exécutées. Il doit être stocké dans une variable afin de pouvoir être utilisé pour annuler le timer ultérieurement.


                    // Utilise "setTimeout()" pour définir une fonction de rappel qui sera exécutée 
                    // après un délai de 1000 millisecondes (1 seconde).
                    // Une fois ce délai écoulé, la fonction affiche le message 
                    // "le délai d'une seconde est passé." dans la console.
                    const timerId = setTimeout(() => {
                        console.log("Le délai d'une seconde est passé.");
                    }, 1000);

                    // Utilise "clearTimeout()" avec l'identifiant du timer (timerId) pour annuler 
                    // l'exécution prévue de la fonction de rappel.
                    clearTimeout(timerId);
                

Voici un exemple où l'on souhaite interrompre l'affichage des secondes avant d'atteindre la quatrième :


                    // Initialise la variable "n" à 1.
                    let n = 1;

                    // Utilise "setInterval()" pour définir une fonction de rappel qui sera exécutée
                    // toutes les 1000 millisecondes (1 seconde).
                    // À chaque exécution, la fonction affiche la valeur actuelle de "n" à l'aide de 
                    // "console.log()", puis incrémente "n" de 1.
                    const timerId = setInterval(() => {
                        console.log(n++);
                    }, 1000);

                    // Utilise "setTimeout()" pour définir une fonction de rappel qui sera exécutée 
                    // après un délai de 3000 millisecondes (3 secondes).
                    // Cette fonction utilise clearInterval avec l'identifiant du timer (timerId) 
                    // pour arrêter l'exécution du "setInterval()" après 3 secondes.
                    setTimeout(() => {
                        clearInterval(timerId);
                    }, 3000);
                    /*
                        Affiche :
                            1
                            2
                            3
                    */
                

Exercices - Part 01

Exo-operations-asynchrones-01

  • Objectif : Créer un compteur de secondes sur une page web qui affiche les secondes passées depuis le chargement de la page.
  • Méthodes et propriétés dont vous pourriez avoir besoin :
    • querySelector()
    • setInterval()
    • textContent

Exo-operations-asynchrones-02

  • Objectif : Créer un compteur de secondes sur une page web qui affiche les secondes passées depuis le chargement de la page mais qui s'arrête après 3 secondes.
  • Objets, méthodes et propriétés dont vous pourriez avoir besoin :
    • querySelector()
    • setInterval()
    • setTimeout()
    • clearInterval()
    • textContent

Exo-operations-asynchrones-03

  • Objectif : Créer un compteur de secondes sur une page web qui affiche les secondes passées depuis le chargement de la page. Ajoutez un bouton permettant de mettre le compteur en pause ou de le réactiver.
  • Objets, méthodes et propriétés dont vous pourriez avoir besoin :
    • querySelector()
    • setInterval()
    • setTimeout()
    • clearInterval()
    • addEventListener()
    • textContent

Fonctions de Rappel

Lorsqu'une fonction de rappel associée à un événement est exécutée, elle s'exécute de manière asynchrone. Cela signifie qu'elle est planifiée pour être exécutée ultérieurement, une fois que l'événement est déclenché, sans bloquer le reste du programme. Ainsi, le code principal continue de s'exécuter normalement, et la fonction de rappel est traitée indépendamment.

Problème d'asynchronisme

Lorsque nous manipulons des images ou d'autres ressources externes, il est important de comprendre que leur chargement ne se fait pas instantanément. Contrairement aux opérations synchrones qui renvoient immédiatement un résultat, le chargement d'une image dépend du temps nécessaire pour récupérer les données depuis le serveur. JavaScript ne suspend pas l'exécution du programme en attendant que l'image soit prête, ce qui signifie que toute tentative d'utiliser l'image immédiatement après l'appel à la fonction de chargement risque d'échouer.

Dans l'exemple ci-dessous, la fonction chargerImage() crée un élément img et déclenche son chargement. Le problème vient du fait que le chargement est asynchrone. La fonction se termine donc avant que l'événement onload ne se déclenche, et elle ne renvoie rien immédiatement. Résultat, la constante image reçoit undefined au lieu de l'élément attendu. Si on ajoute ensuite cette valeur au DOM avec append(), le navigateur insère le texte "undefined", ce qui n'est pas l'effet recherché.


                    function chargerImage(cheminFichierImage) 
                    {
                        // Création d'un nouvel élément "<img>".
                        const img = new Image();

                        // La fonction anonyme sera exécutée une fois l'image entièrement chargée.
                        img.onload = () => 
                        {
                            // Affiche un message dans la console indiquant que l'image est bien chargée
                            console.log(`L'image "${cheminFichierImage}" a été chargée!`);

                            // Retourner l'élément image.
                            return img;
                        };

                        // Déclenche le processus de chargement de l'image en définissant son chemin source.
                        img.src = cheminFichierImage;
                    }

                    // "chargerImage()" est asynchrone et ne retourne pas immédiatement l'élément "<img>". 
                    // Comme aucun retour n'est fourni immédiatement, "image" reçoit "undefined", et non l'élément "<img>" attendu.
                    const image = chargerImage('./assets/images/img-01.jpg');

                    const conteneur = document.querySelector('#images-conteneur');

                    // Ajouter image au DOM uniquement s'il s'agit d'une instance de HTMLImageElement.
                    if (image instanceof HTMLImageElement)
                    {
                        conteneur.append(image);
                    }
                    else
                    {
                        console.log('Image non disponible...');
                    }
                

Pour résoudre ce problème, on peut définir une fonction ajouterImageDansGalerie() dont le rôle sera d'insérer un élément image dans un conteneur spécifique du DOM. Cette fonction pourra être utilisée comme fonction de rappel par chargerImage(). Ainsi, une fois l'image chargée, ajouterImageDansGalerie() sera exécutée pour ajouter la balise img à la galerie.


                    function ajouterImageDansGalerie(image)
                    {
                        // Sélectionne l'élément du DOM qui sert de conteneur pour les images.
                        const conteneur = document.querySelector('#images-conteneur');

                        // Ajoute l'élément "<img>" au conteneur.
                        conteneur.append(image);
                    }

                    function chargerImage(cheminFichierImage, faireQqChoseApresChargement) 
                    {
                        // Création d'un nouvel élément "<img>".
                        const img = new Image();

                        // La fonction anonyme sera exécutée une fois l'image entièrement chargée.
                        img.onload = () => 
                        {
                            console.log(`L'image "${cheminFichierImage}" a été chargée!`);

                            // Appelle la fonction de rappel fournie en argument, en lui passant l'image chargée.
                            // Ici, "faireQqChoseApresChargement" correspond à "ajouterImageDansGalerie()".
                            faireQqChoseApresChargement(img);
                        };

                        // Démarre le chargement de l'image en définissant son "src".
                        img.src = cheminFichierImage;
                    };

                    // Appelle la fonction "chargerImage()" en lui passant "ajouterImageDansGalerie()" comme fonction de rappel.
                    // Cette dernière sera exécutée à l'intérieur de "chargerImage()" dès que l'image sera entièrement chargée.
                    chargerImage('./assets/images/img-01.jpg', ajouterImageDansGalerie);

                    // Ce message s'affiche avant celui indiquant que l'image est chargée, 
                    // ce qui prouve que l'événement "onload" est asynchrone.
                    // "chargerImage()" s'exécute immédiatement, mais "onload" ne s'exécute que plus tard, 
                    // une fois que l'image est complètement chargée.
                    // Pendant ce temps, JavaScript continue l'exécution des instructions suivantes sans attendre.
                    console.log("Le code peut continuer avant même qu'une image ne soit chargée...");
                    /*
                        Affiche :
                            le code peut continuer avant même qu'une image ne soit chargée...
                            L'image "./assets/images/img-01.jpg" a été chargée!
                    */
                

Il est bien sûr possible d'enchaîner le chargement et l'ajout d'images les unes après les autres. Cependant, comme nous allons le voir avec cette technique, les images seront ajoutées dans l'ordre de leur chargement et non dans l'ordre dans lequel elles ont été placées dans le code.


                    function ajouterImageDansGalerie(image)
                    {
                        const conteneur = document.querySelector('#images-conteneur');
                        conteneur.append(image);
                    }

                    function chargerImage(cheminFichierImage, faireQqChoseApresChargement) 
                    {
                        const img = new Image();
                        img.onload = () => 
                        {
                            console.log(`L'image "${cheminFichierImage}" a été chargée!`);
                            faireQqChoseApresChargement(img);
                        };
                        img.src = cheminFichierImage;
                    };

                    // Chemin du dossier où sont stockées les images.
                    const chemin = './assets/images/';

                    // L'ordre d'ajout des images ne sera pas garanti!
                    // Chaque image sera ajoutée dès qu'elle est chargée, ce qui dépend de sa vitesse de chargement.
                    // Ainsi, les images peuvent apparaître dans un ordre différent de leur appel dans le code.
                    chargerImage(chemin + 'img-01.jpg', ajouterImageDansGalerie);
                    chargerImage(chemin + 'img-02.jpg', ajouterImageDansGalerie);
                    chargerImage(chemin + 'img-03.jpg', ajouterImageDansGalerie);
                    chargerImage(chemin + 'img-04.jpg', ajouterImageDansGalerie);

                    console.log("Le code peut continuer avant même qu'une image ne soit chargée...");
                    /*
                        Affiche :
                            le code peut continuer avant même qu'une image ne soit chargée...
                            L'image "./assets/images/img-01.jpg" a été chargée!
                            L'image "./assets/images/img-04.jpg" a été chargée!
                            L'image "./assets/images/img-02.jpg" a été chargée!
                            L'image "./assets/images/img-03.jpg" a été chargée!
                    */
                

Callback Hell (l'enfer des fonctions de rappel)

Pour s'assurer que les images soient ajoutées dans l'ordre prévu, on peut chaîner les appels à la fonction chargerImage() en imbriquant les appels dans des fonctions de rappel. Chaque image ne sera chargée et ajoutée qu'une fois la précédente terminée. Cependant, cette approche entraîne une forte imbrication des fonctions, connue sous le nom de callback hell, rendant le code difficile à lire et à maintenir.


                    function ajouterImageDansGalerie(image)
                    {
                        const conteneur = document.querySelector('#images-conteneur');
                        conteneur.append(image);
                    }

                    function chargerImage(cheminFichierImage, faireQqChoseApresChargement) 
                    {
                        const img = new Image();
                        img.onload = () => 
                        {
                            console.log(`L'image "${cheminFichierImage}" a été chargée!`);
                            faireQqChoseApresChargement(img);
                        };
                        img.src = cheminFichierImage;
                    };

                    // Chemin du dossier où sont stockées les images.
                    const chemin = './assets/images/';

                    // On passe une fonction de rappel en seconde argument de la fonction "chargerImage()".
                    // Cette fonction de rappel sera appelée une fois que l'image sera chargée.
                    chargerImage(chemin + 'img-01.jpg', function(image) 
                    {
                        ajouterImageDansGalerie(image);
                        chargerImage(chemin + 'img-02.jpg', function(image) 
                        {
                            ajouterImageDansGalerie(image);
                            chargerImage(chemin + 'img-03.jpg', function(image) 
                            {
                                ajouterImageDansGalerie(image);
                                chargerImage(chemin + 'img-04.jpg', function(image) 
                                {
                                    ajouterImageDansGalerie(image);
                                });
                            });
                        });
                    });

                    console.log("le code peut continuer avant même qu'une image ne soit chargée...");
                    /*
                        Affiche :
                            le code peut continuer avant même qu'une image ne soit chargée...
                            L'image "./assets/images/img-01.jpg" a été chargée!
                            L'image "./assets/images/img-02.jpg" a été chargée!
                            L'image "./assets/images/img-03.jpg" a été chargée!
                            L'image "./assets/images/img-04.jpg" a été chargée!
                    */
                

Notez que lors de l'utilisation d'opérations asynchrones, les blocs try et catch ne peuvent pas capturer les erreurs de manière classique. En effet, lorsqu'une fonction asynchrone ou une fonction de rappel lève une erreur, celle-ci n'est pas interceptée par un try...catch placé autour de son appel. L'erreur se produit en différé, une fois que le bloc try a déjà terminé son exécution.

Pour gérer ces erreurs avec la technique des fonctions de rappel chaînées, il est nécessaire d'ajouter une fonction de rappel dédiée à la gestion des erreurs. Cela alourdit encore plus l'imbrication des callbacks, rendant le problème du callback hell encore plus complexe et difficile à maintenir.


                    function ajouterImageDansGalerie(image)
                    {
                        const galerie = document.querySelector('#images-conteneur');
                        galerie.append(image);
                    }

                    function gererLesErreurs(erreur)
                    {
                        console.error(erreur.message);
                    }

                    function chargerImage(cheminFichierImage, faireQqChoseApresChargement, faireQqChoseApresErreur) 
                    {
                        const img = new Image();

                        img.onload = () => 
                        {
                            console.log(`L'image "${cheminFichierImage}" a été chargée!`);
                            faireQqChoseApresChargement(img);
                        };

                        img.onerror = () => 
                        {
                            faireQqChoseApresErreur(new Error(`L'image "${cheminFichierImage}" n'a pas été chargée!`));
                        };

                        img.src = cheminFichierImage;
                    };

                    const chemin = './assets/images/';

                    chargerImage(chemin + 'img-01.jpg', function(image) 
                    {
                        ajouterImageDansGalerie(image);
                        chargerImage(chemin + 'img-02.jpg', function(image) 
                        {
                            ajouterImageDansGalerie(image);
                            chargerImage(chemin + 'img-03.jpg', function(image) 
                            {
                                ajouterImageDansGalerie(image);
                                chargerImage(chemin + 'img-04.jpg', function(image) 
                                {
                                    ajouterImageDansGalerie(image);
                                }, function(erreur)
                                {
                                    gererLesErreurs(erreur)
                                });
                            }, function(erreur)
                            {
                                gererLesErreurs(erreur)
                            });
                        }, function(erreur)
                        {
                            gererLesErreurs(erreur)
                        });
                    }, function(erreur)
                    {
                        gererLesErreurs(erreur)
                    });

                    console.log("le code peut continuer avant même qu'une image ne soit chargée...");
                    /*
                        Affiche :
                            le code peut continuer avant même qu'une image ne soit chargée...
                            L'image "./assets/images/img-01.jpg" a été chargée!
                            L'image "./assets/images/img-02.jpg" a été chargée!
                            L'image "./assets/images/img-03.jpg" a été chargée!
                            L'image "./assets/images/img-04.jpg" a été chargée!
                    */
                

L'utilisation de fonctions de rappel pour gérer des opérations asynchrones enchaînées peut rapidement rendre le code difficile à lire et à maintenir. Cette complexité apparaît surtout lorsque chaque opération dépend du résultat de la précédente, nécessitant une exécution séquentielle.

Heureusement, JavaScript offre des solutions modernes pour simplifier la gestion des opérations asynchrones. Avec les promesses, il est possible d'écrire un code plus lisible, structuré et facile à maintenir, tout en conservant un enchaînement logique des actions.

Les Promesses

Les promesses (Promises) sont un moyen de gérer des opérations asynchrones de manière plus claire et structurée. Elles sont particulièrement utiles lorsque vous effectuez des tâches telles que le chargement d'images, les requêtes réseau ou l'accès à des bases de données, où le résultat n'est pas immédiatement disponible.

Une promesse représente une valeur qui peut être disponible maintenant, dans le futur, ou jamais. Elle permet de définir des actions à effectuer lorsque la valeur est résolue resolve avec succès ou lorsqu'il y a une erreur reject. Cela permet de séquencer les opérations asynchrones de manière plus lisible, en évitant le callback hell :


                    function ajouterImageDansGalerie(image)
                    {
                        const galerie = document.querySelector('#images-conteneur');
                        galerie.append(image);
                    }

                    function chargerImage(cheminFichierImage) 
                    {
                        return new Promise((resolve, reject) => 
                        {
                            // Déclaration de la constante initialiser avec une nouvelle instance de l'objet "Image".
                            const img = new Image();

                            // Lorsque l'image est chargée avec succès.
                            img.onload = () => 
                            {
                                console.log(`L'image "${cheminFichierImage}" a été chargée!`);
                                // La Promesse est résolue avec succès, ce qui déclenchera la méthode "then" associée.
                                resolve(img);
                            };

                            // En cas d'erreur lors du chargement de l'image.
                            img.onerror = () => 
                            {
                                const errorMessage = 'Échec du chargement de l\'image : ' + cheminFichierImage;
                                // La Promesse est rejetée avec une erreur, ce qui déclenchera le premier "catch" rencontré après l'exécution du "then" correspondant à cette fonction.
                                reject(new Error(errorMessage));
                            };

                            // Ajout de l'attribut "src" avec le chemin vers le fichier image à charger.
                            img.src = cheminFichierImage;
                        });
                    }

                    // Chemin du dossier où sont stockées les images.
                    const chemin = 'assets/images/';

                    // Utilisation des Promises pour ajouter plusieurs images de manière séquentielle.
                    // Si une erreur survient, le chargement des images s'interrompt et le bloc catch est appelé.
                    chargerImage(chemin + 'img-01.jpg')
                        .then((img) =>
                        {
                            ajouterImageDansGalerie(img);
                            return chargerImage(chemin + 'img-02.jpg');
                        })
                        .then((img) =>
                        {
                            ajouterImageDansGalerie(img);
                            return chargerImage(chemin + 'img-03.jpg');
                        })
                        .then((img) =>
                        {
                            ajouterImageDansGalerie(img);
                            return chargerImage(chemin + 'img-04.jpg');
                        })
                        .then((img) =>
                        {
                            ajouterImageDansGalerie(img);
                        })
                        .catch((erreur) =>
                        {
                            console.error(erreur.message);
                        });

                        console.log("le code peut continuer avant même qu'une image ne soit chargée...");
                    /*
                        Affiche :
                            le code peut continuer avant même qu'une image ne soit chargée...
                            L'image "assets/images/img-01.jpg" a été ajoutée!
                            L'image "assets/images/img-02.jpg" a été ajoutée!
                            L'image "assets/images/img-03.jpg" a été ajoutée!
                            L'image "assets/images/img-04.jpg" a été ajoutée!
                    */
                
  1. Création d'une promesse : Nous créons une promesse en utilisant le constructeur Promise. Une promesse est comme un engagement à effectuer une tâche. Dans notre exemple, cette tâche consiste à charger une image depuis un fichier.
  2. Resolve et Reject : Avec les promesses, nous pouvons dire si notre tâche a réussi (resolve) ou échoué (reject). Par exemple, lorsque l'image se charge correctement, nous disons que la promesse est "résolue" avec succès en appelant resolve(). Si une erreur se produit lors du chargement de l'image, nous disons que la promesse est "rejetée" en appelant reject().
  3. Utilisation des méthodes then() et catch() : Nous utilisons les méthodes then() pour indiquer ce que nous voulons faire une fois que notre tâche est terminée avec succès. Par exemple, après avoir ajouté une image, nous disons "Ensuite, ajoute une autre image". La méthode catch() nous permet de gérer les erreurs qui surviennent en cours de route. Si quelque chose ne va pas, nous pouvons dire Oh non, une erreur s'est produite ! et afficher un message d'erreur.
  4. Exécution séquentielle : Nous pouvons utiliser then() pour chaîner nos tâches de manière séquentielle. Cela signifie que chaque tâche attend que la précédente soit terminée avant de commencer. Dans notre exemple, chaque image attend que la précédente soit chargée avant de charger la suivante. Cela garantit que les images sont ajoutées dans l'ordre que nous voulons.
  5. Continuité de l'exécution : Le reste du code peut continuer à s'exécuter même pendant le chargement des images. Les promesses travaillent en arrière-plan, ce qui signifie que notre programme ne reste pas bloqué à attendre que chaque image se charge. Cela permet à notre code de rester réactif et de continuer à fonctionner normalement.

Si vous avez besoin d'exécuter un bloc d'instructions, peu importe si une promesse réussit ou échoue, vous pouvez utiliser le bloc finally comme vous l'auriez fait à la suite des blocs try() et catch. Pour rappel, ce bloc est généralement utilisée pour s'assurer que des opérations de nettoyage ou de finalisation sont effectuées. Cela garantit que certaines actions critiques, comme la fermeture de fichiers, la libération de ressources, ou la gestion de tout ce qui doit être fait indépendamment du succès ou de l'échec de la promesse, sont réalisées de manière fiable.

Pour améliorer la lisibilité du code, il est possible de gérer ce groupe de promesses en utilisant la méthode Promise.all(). Cette méthode offre l'avantage de lancer toutes les promesses en parallèle, puis d'attendre que toutes aient terminé leur exécution avant de poursuivre. Dans l'exemple précédent, le chargement d'une image débute dès que l'image précédente a terminé son chargement. Avec Promise.all(), toutes les images sont chargées simultanément, mais le bloc d'instructions then() ne s'exécutera qu'une fois que toutes les images seront chargées, et les valeurs renvoyées par les résolutions seront récupérées dans un tableau respectant l'ordre des appels :


                    function chargerImage(cheminFichierImage) 
                    {
                        return new Promise((resolve, reject) => 
                        {
                            // Déclaration de la constante initialiser avec une nouvelle instance de l'objet "Image".
                            const img = new Image();

                            // Lorsque l'image est chargée avec succès.
                            img.onload = () => 
                            {
                                // Si toutes les promesses sont résolues, la méthode "then" est exécutée et toutes les images chargées lui sont passées.
                                resolve(img);
                            };

                            // En cas d'erreur lors du chargement de l'image.
                            img.onerror = () => 
                            {
                                const errorMessage = 'Échec du chargement de l\'image : ' + cheminFichierImage;
                                // La Promesse est rejetée avec une erreur, ce qui déclenchera le premier "catch" rencontré après l'exécution du "then" correspondant à cette fonction.
                                reject(new Error(errorMessage));
                            };

                            // Ajout de l'attribut "src" avec le chemin vers le fichier image à charger.
                            img.src = cheminFichierImage;
                        });
                    }

                    // Chemin du dossier où sont stockées les images.
                    const chemin = 'assets/images/';

                    // Utilisation de Promise.all() pour permettre les chargement en parallèle tout en les ajoutant au DOM de manière séquentielle une fois tous les chargement terminés.
                    // Si une erreur survient, le chargement des images s'interrompt et le bloc catch est appelé.
                    Promise.all([
                        chargerImage(chemin + 'img-01.jpg'),
                        chargerImage(chemin + 'img-02.jpg'),
                        chargerImage(chemin + 'img-03.jpg'),
                        chargerImage(chemin + 'img-04.jpg')
                    ])
                        .then((images) =>
                        {
                            // Sélection du conteneur dans lequel les images seront ajoutées.
                            const conteneur = document.querySelector('#images-conteneur');
                            
                            // Ajouter chaque image au conteneur :
                            images.forEach((image) => 
                            {
                                conteneur.append(image);
                            });
                        })
                        .catch((erreur) =>
                        {
                            console.error(erreur.message);
                        });

                    console.log("le code peut continuer avant même qu'une image ne soit chargée...");
                    /*
                        Affiche :
                            le code peut continuer avant même qu'une image ne soit chargée...
                            L'image "assets/images/img-01.jpg" a été ajoutée!
                            L'image "assets/images/img-02.jpg" a été ajoutée!
                            L'image "assets/images/img-03.jpg" a été ajoutée!
                            L'image "assets/images/img-04.jpg" a été ajoutée!
                    */
                

Toutes les images sont chargées en parallèle, mais elles ne s'ajoutent au conteneur qu'une seule fois qu'elles sont toutes chargées, et ce, dans l'ordre désiré. Cette approche optimise le processus de chargement et garantit que les images apparaissent dans la séquence souhaitée. Cependant, il y a un problème potentiel, si l'une des images rencontre un problème lors du chargement, le bloc catch() est appelé, et aucune image ne sera ajoutée au DOM. Pour contourner ce problème, vous pouvez utiliser la méthode allSettled() plutôt que all() :


                    function chargerImage(cheminFichierImage) 
                    {
                        return new Promise((resolve, reject) => 
                        {
                            // Déclaration de la constante initialiser avec une nouvelle instance de l'objet "Image".
                            const img = new Image();

                            // Lorsque l'image est chargée avec succès.
                            img.onload = () => 
                            {
                                // Si toutes les promesses sont résolues, la méthode "then" est exécutée et toutes les images chargées lui sont passées.
                                resolve(img);
                            };

                            // En cas d'erreur lors du chargement de l'image.
                            img.onerror = () => 
                            {
                                const errorMessage = 'Échec du chargement de l\'image : ' + cheminFichierImage;
                                // La Promesse est rejetée avec une erreur, ce qui déclenchera le premier "catch" rencontré après l'exécution du "then" correspondant à cette fonction.
                                reject(new Error(errorMessage));
                            };

                            // Ajout de l'attribut "src" avec le chemin vers le fichier image à charger.
                            img.src = cheminFichierImage;
                        });
                    }

                    // Chemin du dossier où sont stockées les images.
                    const chemin = 'assets/images/';

                    // Utilisation de Promise.allSettled() pour permettre les chargements en parallèle tout en les ajoutant au DOM de manière séquentielle une fois tous les chargements terminés.
                    // Si une erreur survient, le chargement des images ne s'interrompt pas.
                    // Il n'y a plus de bloc "catch()", c'est le bloc "then()" qui s'occupe de tout.
                    Promise.allSettled([
                        chargerImage(chemin + 'img-01.jpg'),
                        chargerImage(chemin + 'img-02.jpg'),
                        chargerImage(chemin + 'img-03.jpg'),
                        chargerImage(chemin + 'img-04.jpg')
                    ])
                        .then((reponses) =>
                        {
                            // Sélection du conteneur dans lequel les images seront ajoutées.
                            const conteneur = document.querySelector('#images-conteneur');
                            
                            for (const reponse of reponses)
                            {
                                if (reponse.status === 'fulfilled')
                                {
                                    // Ajouter l'image à la galerie.
                                    conteneur.append(reponse.value);
                                }
                                else
                                {
                                    console.error(reponse.reason);
                                }
                            }
                        });

                    console.log("le code peut continuer avant même qu'une image ne soit chargée...");
                    /*
                        Affiche :
                            le code peut continuer avant même qu'une image ne soit chargée...
                            L'image "assets/images/img-01.jpg" a été ajoutée!
                            L'image "assets/images/img-02.jpg" a été ajoutée!
                            L'image "assets/images/img-03.jpg" a été ajoutée!
                            L'image "assets/images/img-04.jpg" a été ajoutée!
                    */
                

Comme illustré dans l'exemple précédent, la méthode allSettled() se distingue par sa gestion des succès et des erreurs au sein du bloc then(). Lorsque le traitement des Promesses est achevé, elle retourne un tableau de résultats. Chaque élément de ce tableau est un objet contenant des informations telles que l'état de la Promesse (réussie ou rejetée), ainsi que les valeurs transmises par les fonctions de rappel resolve() et reject(). Cette approche permet de regrouper de manière organisée les résultats de toutes les Promesses pour une gestion plus aisée des succès et des échecs.

Chaque objet du tableau a deux propriété principale :

  • status : Indique l'état de la Promesse d'origine, qui peut être fulfilled (résolue avec succès) ou rejected (rejetée en raison d'une erreur).
  • value ou reason :
    • value : La valeur retournée par resolve (si la Promesse a été résolue avec succès).
    • reason : La valeur retournée par reject (si la Promesse a échouée).

Exercices - Part 02

Exo-operations-asynchrones-04

  • Objectif : Récupérer les informations d'un utilisateur via une promesse retournée par une fonction simulant une requête vers le serveur.
  • Instructions :
    1. Créez une fonction simulerRequete() qui renvoie une promesse.
    2. Cette fonction prend deux paramètres :
      • idUtilisateur : l'index dans un tableau d'utilisateurs.
      • delaiAvantReponse : le délai (en millisecondes) pour simuler l'attente avant réception de la réponse.
    3. Dans simulerRequete(), déclarez le tableau d'utilisateurs :
      
                                          const utilisateurs = [
                                              // Id utilisateur : 0
                                              {
                                                  nom: "Focan",
                                                  prenom: "Claudy",
                                                  email: "claudy.focan@gmail.com"
                                              },
                                              // Id utilisateur : 1
                                              {
                                                  nom: "Groot",
                                                  prenom: "Groot",
                                                  email: "jesappellegroot@gmail.com"
                                              },
                                              // Id utilisateur : 2
                                              {
                                                  nom: "Burton",
                                                  prenom: "Jack",
                                                  email: "jack.burton@gmail.com"
                                              }
                                          ];
                                      
    4. Dans la promesse, utilisez setTimeout() pour simuler le délai d'attente à l'aide du paramètre delaiAvantReponse.
    5. Une fois le délai écoulé, tentez de récupérer les données de l'utilisateur dans le tableau utilisateurs à l'index idUtilisateur.
      • Si l'utilisateur existe :
        • Appelez resolve() en lui passant les informations de cet utilisateur.
        • Dans le bloc then(), récupérez les données renvoyées et affichez-les avec console.log().
      • Si l'utilisateur n'existe pas :
        • Appelez reject() avec new Error("Id utilisateur invalide !").
        • Dans le bloc catch(), récupérez l'erreur et affichez-la avec console.error().
    6. Testez d'abord la fonction simulerRequete() avec un idUtilisateur valide et observez le résultat dans la console.
    7. Ensuite, testez la fonction simulerRequete() avec un idUtilisateur invalide pour vérifier que la gestion d'erreur fonctionne correctement.

Exo-operations-asynchrones-05

  • Objectif : Récupérer successivement les informations des utilisateurs en utilisant une promesse retournée par une fonction simulant une requête vers un serveur.
  • Instructions :
    1. Dupliquez l'exercice Exo-operations-asynchrones-04 et renommez le dossier du projet en Exo-operations-asynchrones-05.
    2. Simulez 3 requêtes exécutées en chaîne, c'est-à-dire que chaque requête doit être lancée uniquement après la réussite de la précédente.
      • La première requête doit récupérer les données de l'utilisateur ayant l'ID 1 avec un délai de 3 secondes.
      • La seconde requête doit récupérer les données de l'utilisateur ayant l'ID 2 avec un délai de 1 seconde.
      • La troisième requête doit récupérer les données de l'utilisateur ayant l'ID 0 avec un délai de 1 seconde.
    3. Exécutez le script et observez le résultat dans la console.
    4. Ajoutez une nouvelle requête avec un Id invalide entre la première et la deuxième requête, puis observez le résultat dans la console.

Exo-operations-asynchrones-06

  • Objectif : Récupérer les informations des utilisateurs en parallèle et les afficher uniquement une fois qu'elles ont toutes été récupérées avec succès.
  • Instructions :
    1. Dupliquez l'exercice Exo-operations-asynchrones-05 et renommez le dossier du projet en Exo-operations-asynchrones-06.
    2. Simulez 3 requêtes exécutées en parallèle, c'est-à-dire que toutes les requêtes doivent être lancées en même temps, mais leurs résultats ne doivent être affichés dans la console qu'une fois toutes les requêtes terminées avec succès. Les requêtes à effectuer sont les mêmes que dans l'exercice précédent :
      • La première requête doit récupérer les données de l'utilisateur ayant l'ID 1 avec un délai de 3 secondes.
      • La deuxième requête doit récupérer les données de l'utilisateur ayant l'ID 2 avec un délai de 1 seconde.
      • La troisième requête doit récupérer les données de l'utilisateur ayant l'ID 0 avec un délai de 1 seconde.
    3. Exécutez le script et observez le résultat dans la console.
    4. Ajoutez une nouvelle requête avec un ID invalide entre la première et la deuxième requête, puis observez le comportement du programme et le résultat dans la console.

Exo-operations-asynchrones-07

  • Objectif :
    • Réalisez une fonction qui retourne une promesse. Cette promesse a pour objectif de retourner une image, une fois chargée. L'URL de l'image est passé en argument de la fonction.
    • Les images doivent pouvoir être chargées en parallèle.
    • Une fois que toutes les promesses ont retourné une réponse, les images sont ajoutées au DOM en respectant l'ordre suivant :
      1. iainit-midjourney-design-robot-leonardo-da-vinci-912w.jpg
      2. iainit-midjourney-recherche-de-chemin-minimaliste-912w.jpg
      3. iainit-midjourney-theorie-des-jeux-912w.jpg
      4. iainit-midjourney-perlimpinpin-912w.jpg
      5. iainit-midjourney-etoiles-connectees-reseau-neurones-912w.jpg
    • Toutes ces images sont situées dans le dossier https://iainit.cvmdev.be/assets/images/.
    • Si certains chargement échouent, ils ne doivent pas interrompre la suite des événements (les éléments chargés devront être ajoutés au DOM).

Async et Await

Les mots-clés async et await permettent de simplifier la gestion des opérations asynchrones en rendant le code plus lisible et plus facile à gérer. Il est particulièrement utile lorsqu'il s'agit d'effectuer des opérations telles que les requêtes réseau, la lecture/écriture de fichiers ou toute autre tâche qui prend du temps sans bloquer l'exécution du code.

Le mot-clé async est utilisé pour déclarer qu'une fonction est asynchrone. Une fonction asynchrone est une fonction qui peut contenir des opérations asynchrones, et elle renvoie toujours une promesse.

Le mot-clé await est utilisé à l'intérieur d'une fonction asynchrone pour attendre la résolution d'une promesse. Cela signifie que le code suivant await ne sera exécuté que lorsque la promesse sera résolue avec succès.


                    function ajouterImageDansGalerie(image)
                    {
                        const galerie = document.querySelector('#images-conteneur');
                        galerie.append(image);
                    }

                    function chargerImage(cheminFichierImage) 
                    {
                        return new Promise((resolve, reject) => 
                        {
                            // Déclaration de la constante initialiser avec une nouvelle instance de l'objet "Image".
                            const img = new Image();

                            // Lorsque l'image est chargée avec succès.
                            img.onload = () => 
                            {
                                console.log(`L'image "${cheminFichierImage}" a été chargée!`);
                                // La Promesse est résolue avec succès, ce qui déclenchera la méthode "then" associée.
                                resolve(img);
                            };

                            // En cas d'erreur lors du chargement de l'image.
                            img.onerror = () => 
                            {
                                const errorMessage = 'Échec du chargement de l\'image : ' + cheminFichierImage;
                                // La Promesse est rejetée avec une erreur, ce qui déclenchera le premier "catch" rencontré après l'exécution du "then" correspondant à cette fonction.
                                reject(new Error(errorMessage));
                            };

                            // Ajout de l'attribut "src" avec le chemin vers le fichier image à charger.
                            img.src = cheminFichierImage;
                        });
                    };

                    // Chemin du dossier où sont stockées les images.
                    const chemin = 'assets/images/';

                    // Définition d'une fonction asynchrone nommée "ajouterImages()"
                    async function ajouterImages()
                    {
                        try 
                        {
                            // Utilisation du mot-clé "await" pour attendre la résolution de chaque promesse avant de passer à la suivante :
                            // Note : Pour faciliter la compréhension, je n'ai pas utilisé de structure itérative.
                            const img1 = await chargerImage(chemin + 'img-01.jpg');
                            ajouterImageDansGalerie(img1);

                            const img2 = await chargerImage(chemin + 'img-02.jpg');
                            ajouterImageDansGalerie(img1-2);

                            const img3 = await chargerImage(chemin + 'img-03.jpg');
                            ajouterImageDansGalerie(img3);

                            const img4 = await chargerImage(chemin + 'img-04.jpg');
                            ajouterImageDansGalerie(img4);
                        }
                        catch(e)
                        {
                            console.error('Erreur :', e.message);
                        }
                    };

                    // Appeler la fonction qui va s'occuper d'ajouter les images une fois chargées.
                    ajouterImages();

                    console.log("le code peut continuer avant même qu'une image ne soit chargée...");
                    /*
                        Affiche :
                            le code peut continuer avant même qu'une image ne soit chargée...
                            L'image "assets/images/img-01.jpg" a été ajoutée!
                            L'image "assets/images/img-02.jpg" a été ajoutée!
                            L'image "assets/images/img-03.jpg" a été ajoutée!
                            L'image "assets/images/img-04.jpg" a été ajoutée!
                    */
                

Bien que cette approche présente un avantage indéniable en termes de lisibilité et de traitement séquentiel, l'utilisation de Promise.all() et Promise.allSettled() offre une plus grande flexibilité, notamment lorsqu'il s'agit de charger plusieurs ressources simultanément.

Exercices - Part 03

Exo-operations-asynchrones-08

  • Objectif : Récupérer successivement les informations des utilisateurs en utilisant une promesse retournée par une fonction simulant une requête serveur. L'objectif est d'améliorer la lisibilité du code en exploitant les instructions async et await.
  • Instructions :
    1. Dupliquez l'exercice Exo-operations-asynchrones-05 et renommez le dossier du projet en Exo-operations-asynchrones-08.
    2. Dans ce nouvel exercice, remplacez l'utilisation des promesses avec .then() et .catch() par une approche utilisant async et await.
    3. Faites en sorte de récupérer successivement les données des utilisateurs ayant les ID suivants :
      • 1, avec un délai de 3 secondes
      • 2, avec un délai de 1 seconde
      • 0, avec un délai de 1 seconde
      Chaque requête doit attendre la fin de la précédente avant d'être exécutée.
    4. Affichez les résultats dans la console après chaque requête pour observer l'ordre d'exécution.
    5. Ajoutez un bloc try/catch pour gérer les erreurs si une requête échoue.
    6. Testez votre script en ajoutant une requête avec un ID invalide pour voir comment votre programme réagit aux erreurs.

La Méthode requestAnimationFrame

requestAnimationFrame() est une fonction native du navigateur qui permet de planifier une fonction pour être exécutée avant le prochain rafraîchissement de l'écran. Cela permet de créer des animations optimisées, car elles sont synchronisées avec le taux de rafraîchissement de l'écran, généralement à 60 images par seconde (60 FPS).

Lorsque requestAnimationFrame() exécute sa fonction de rappel, elle lui transmet automatiquement un argument que l'on nomme en général timestamp. Ce timestamp représente le temps écoulé en millisecondes depuis le début du chargement de la page. C'est un outil essentiel pour garantir que les animations se déplacent à une vitesse constante, quel que soit le taux de rafraîchissement de l'écran (60 Hz, 144 Hz, etc.) ou les éventuelles pertes de frames.

Exemple 1 : Animation fluide avec delta time

Le principe fondamental est de calculer le temps écoulé depuis la dernière frame (appelé delta time) et de l'utiliser pour ajuster le déplacement. Ainsi, un écran à 60 FPS et un écran à 144 FPS produiront exactement la même vitesse de déplacement :


                    const boiteAnimee = document.getElementById('boite');
                    const VITESSE = 100; // Vitesse souhaitée en pixels par seconde.
                    let position = 0;
                    let dernierTimestamp = null;

                    function animer(timestamp) 
                    {
                        // Au premier appel, on initialise le timestamp de référence.
                        if (dernierTimestamp === null) 
                        {
                            dernierTimestamp = timestamp;
                        }

                        // Calculer le temps écoulé depuis la dernière frame (en secondes).
                        const deltaTemps = (timestamp - dernierTimestamp) / 1000;
                        dernierTimestamp = timestamp;

                        // Déplacer proportionnellement au temps écoulé :
                        // à 60 FPS, deltaTemps ≈ 0.0167s → déplacement ≈ 1.67px
                        // à 144 FPS, deltaTemps ≈ 0.0069s → déplacement ≈ 0.69px
                        // Dans les deux cas, la vitesse totale reste de 100px/s.
                        position += VITESSE * deltaTemps;

                        if (position > window.innerWidth) 
                        {
                            position = 0;
                        }

                        boiteAnimee.style.left = position + 'px';

                        // Planifier la prochaine frame.
                        requestAnimationFrame(animer);
                    }

                    // Démarrer l'animation.
                    requestAnimationFrame(animer);
                

Sans le delta time, le déplacement dépendrait directement du nombre de frames par seconde : l'animation serait plus rapide sur un écran à 144 Hz que sur un écran à 60 Hz. En multipliant la vitesse par le temps écoulé, on obtient un mouvement constant et prévisible, indépendamment du matériel.

Exemple 2 : Limiter le nombre de mises à jour par seconde

Dans certains cas (jeux vidéo, animations complexes), on souhaite limiter la fréquence à laquelle la logique de l'animation s'exécute, par exemple à 30 FPS, tout en conservant la synchronisation avec l'écran offerte par requestAnimationFrame(). Le timestamp permet de n'exécuter la logique que lorsqu'un intervalle minimum de temps s'est écoulé :


                    const boiteAnimee = document.getElementById('boite');
                    const IMAGES_PAR_SECONDE = 30;
                    const INTERVALLE_MINIMUM = 1000 / IMAGES_PAR_SECONDE; // ≈ 33.33ms
                    let position = 0;
                    let dernierTimestamp = 0;

                    function animer(timestamp) 
                    {
                        const tempsEcoule = timestamp - dernierTimestamp;

                        // N'exécuter la logique que si l'intervalle minimum est atteint.
                        if (tempsEcoule >= INTERVALLE_MINIMUM) 
                        {
                            dernierTimestamp = timestamp;

                            position += 2;

                            if (position > window.innerWidth) 
                            {
                                position = 0;
                            }

                            boiteAnimee.style.left = position + 'px';
                        }

                        // Continuer la boucle, même si on n'a pas mis à jour cette frame.
                        requestAnimationFrame(animer);
                    }

                    // Démarrer l'animation.
                    requestAnimationFrame(animer);
                

La boucle requestAnimationFrame() continue de tourner à la fréquence native de l'écran, mais la logique de déplacement ne s'exécute que ~30 fois par seconde. Ce mécanisme est plus fiable que setInterval() pour le contrôle de fréquence, car il reste synchronisé avec le cycle de rendu du navigateur et se met automatiquement en pause lorsque l'onglet est inactif, économisant ainsi les ressources système.

Notez que dans le développement de jeux vidéo, il est courant de séparer strictement la logique (physique, IA, détection de collisions) du rendu visuel, chacune tournant à sa propre fréquence. On parle de fixed timestep. Ce pattern se justifie lorsque la simulation doit être déterministe ou synchronisée en réseau. En animation web classique, cette séparation est rarement nécessaire, le delta time au sein de requestAnimationFrame() suffit à garantir un mouvement constant, tout en évitant les problèmes d'imprécision liés à setInterval().