Article original : When to Use Async/Await vs Promises in JavaScript

JavaScript est un langage de programmation asynchrone, ce qui signifie qu'il peut gérer plusieurs opérations en même temps sans bloquer le thread principal. Lorsque vous travaillez avec des opérations asynchrones comme des appels API, la lecture de fichiers ou des requêtes de base de données, vous avez deux approches principales : les Promesses et Async/Await.

Dans cet article, vous apprendrez les différences entre ces deux approches, quand utiliser chacune d'elles, et comment faire le bon choix pour votre cas d'utilisation spécifique.

Voici ce que nous allons couvrir :

  1. Qu'est-ce que les opérations asynchrones ?

  2. Qu'est-ce que les Promesses ?

  3. Qu'est-ce que Async/Await ?

  4. Exemples pratiques : Promesses vs Async/Await

  5. Quand utiliser les Promesses

  6. Quand utiliser Async/Await

  7. Considérations de performance

  8. Modèles de gestion des erreurs

  9. Bonnes pratiques

  10. Faire le bon choix

  11. Conclusion

Qu'est-ce que les opérations asynchrones ?

Avant d'expliquer ce que signifient les Promesses et Async/Await, il est important de comprendre ce que sont les opérations asynchrones.

Les opérations synchrones s'exécutent les unes après les autres, bloquant l'opération suivante jusqu'à ce que l'opération en cours soit terminée. Voici un exemple en JavaScript :

console.log("Premier");
console.log("Deuxième");
console.log("Troisième");

// Sortie :
// Premier
// Deuxième
// Troisième

Les opérations asynchrones, en revanche, peuvent démarrer une opération et continuer à exécuter d'autres codes tout en attendant que la première opération se termine. Voici un exemple en JavaScript :

console.log("Premier");

setTimeout(() => {
    console.log("Deuxième (après 2 secondes)");
}, 2000);

console.log("Troisième");

// Sortie :
// Premier
// Troisième
// Deuxième (après 2 secondes)

Dans cet exemple, setTimeout() est une fonction asynchrone qui planifie l'exécution de code après un délai spécifié sans bloquer l'exécution du code suivant.

Qu'est-ce que les Promesses ?

Une Promesse est un objet JavaScript qui représente l'achèvement éventuel (ou l'échec) d'une opération asynchrone. Considérez-la comme un espace réservé pour une valeur qui sera disponible à l'avenir.

États des Promesses

Une Promesse peut être dans l'un des trois états suivants :

  1. En attente : L'état initial – l'opération n'a pas encore été complétée

  2. Exécutée (Résolue) : L'opération a été complétée avec succès

  3. Rejetée : L'opération a échoué

Syntaxe de base des Promesses

Voici comment créer et utiliser une Promesse de base :

// Création d'une Promesse
const maPromesse = new Promise((resolve, reject) => {
    // Simuler une opération asynchrone
    setTimeout(() => {
        const succes = true;

        if (succes) {
            resolve("Opération terminée avec succès !");
        } else {
            reject("Opération échouée !");
        }
    }, 2000);
});

// Utilisation de la Promesse
maPromesse
    .then((resultat) => {
        console.log(resultat); // "Opération terminée avec succès !"
    })
    .catch((erreur) => {
        console.log(erreur);
    });

Analysons ce code :

  • new Promise() crée un nouvel objet Promesse

  • Le constructeur de la Promesse prend une fonction avec deux paramètres : resolve et reject

  • resolve() est appelé lorsque l'opération réussit

  • reject() est appelé lorsque l'opération échoue

  • .then() gère le cas de succès

  • .catch() gère le cas d'erreur

Chaînage des Promesses

Le chaînage des Promesses est une technique puissante qui permet de lier plusieurs opérations asynchrones ensemble dans une séquence. Lorsque vous souhaitez effectuer plusieurs opérations où chacune dépend du résultat de la précédente, le chaînage des Promesses offre une solution élégante. Vous pouvez chaîner plusieurs Promesses ensemble en utilisant .then() :

function fetchUserData(userId) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve({ id: userId, name: "John Doe" });
        }, 1000);
    });
}

function fetchUserPosts(user) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve([
                { title: "Post 1", author: user.name },
                { title: "Post 2", author: user.name }
            ]);
        }, 1000);
    });
}

// Chaînage des Promesses
fetchUserData(123)
    .then((user) => {
        console.log("Utilisateur :", user);
        return fetchUserPosts(user);
    })
    .then((posts) => {
        console.log("Posts :", posts);
    })
    .catch((error) => {
        console.log("Erreur :", error);
    });

Dans cet exemple :

  • fetchUserData() retourne une Promesse qui se résout avec les informations de l'utilisateur

  • fetchUserPosts() retourne une Promesse qui se résout avec les posts de l'utilisateur

  • Nous chaînons ces opérations en utilisant .then()

  • Chaque .then() reçoit la valeur résolue de la Promesse précédente

Inconvénients du chaînage des Promesses :

Bien que le chaînage des Promesses soit puissant, il présente certains inconvénients potentiels :

  1. "L'enfer des callbacks" déguisé : Les chaînes complexes peuvent devenir difficiles à lire et à déboguer, surtout avec une logique imbriquée

  2. Gestion des erreurs complexe : Chaque étape de la chaîne nécessite une gestion des erreurs appropriée, et les erreurs peuvent se propager de manière inattendue

  3. Défis de débogage : Les traces de pile à travers les chaînes de Promesses peuvent être plus difficiles à suivre

  4. Mélange de logique synchrone et asynchrone : Il peut être tentant de mettre des opérations synchrones à l'intérieur des blocs .then(), ce qui peut entraîner de la confusion

Qu'est-ce que Async/Await ?

Async/Await est une syntaxe simplifiée construite sur les Promesses. Il permet d'écrire du code asynchrone qui ressemble et se comporte plus comme du code synchrone, le rendant plus facile à lire et à comprendre.

Syntaxe de base d'Async/Await

Voici le même exemple de Promesse réécrit en utilisant Async/Await :

// Création d'une fonction asynchrone
async function performOperation() {
    try {
        const result = await myPromise;
        console.log(result); // "Opération terminée avec succès !"
    } catch (error) {
        console.log(error);
    }
}

performOperation();

Analysons ce code :

  • Le mot-clé async avant une déclaration de fonction en fait une fonction asynchrone

  • Le mot-clé await met en pause l'exécution de la fonction jusqu'à ce que la Promesse soit résolue

  • Les blocs try/catch gèrent les erreurs, similaires à .catch() dans les Promesses

Conversion des chaînes de Promesses en Async/Await

Voici l'exemple précédent de chaînage en utilisant Async/Await :

async function getUserDataAndPosts(userId) {
    try {
        const user = await fetchUserData(userId);
        console.log("Utilisateur :", user);

        const posts = await fetchUserPosts(user);
        console.log("Posts :", posts);

        return posts;
    } catch (error) {
        console.log("Erreur :", error);
        throw error; // Relancer l'erreur si nécessaire
    }
}

getUserDataAndPosts(123);

Ce code est beaucoup plus lisible et suit un flux linéaire plus facile à comprendre.

Exemples pratiques : Promesses vs Async/Await

Comparons les deux approches avec des scénarios réels.

Exemple 1 : Faire des appels API

En utilisant les Promesses :

function fetchDataWithPromises() {
    fetch('https://jsonplaceholder.typicode.com/users/1')
        .then(response => {
            if (!response.ok) {
                throw new Error('La réponse du réseau n\'est pas correcte');
            }
            return response.json();
        })
        .then(user => {
            console.log('Données utilisateur :', user);
            return fetch(`https://jsonplaceholder.typicode.com/users/${user.id}/posts`);
        })
        .then(response => response.json())
        .then(posts => {
            console.log('Posts utilisateur :', posts);
        })
        .catch(error => {
            console.error('Erreur :', error);
        });
}

En utilisant Async/Await :

async function fetchDataWithAsyncAwait() {
    try {
        const userResponse = await fetch('https://jsonplaceholder.typicode.com/users/1');

        if (!userResponse.ok) {
            throw new Error('La réponse du réseau n\'est pas correcte');
        }

        const user = await userResponse.json();
        console.log('Données utilisateur :', user);

        const postsResponse = await fetch(`https://jsonplaceholder.typicode.com/users/${user.id}/posts`);
        const posts = await postsResponse.json();
        console.log('Posts utilisateur :', posts);

    } catch (error) {
        console.error('Erreur :', error);
    }
}

La version Async/Await est plus lisible et suit un flux naturel de haut en bas.

Exemple 2 : Gestion de plusieurs opérations asynchrones

En utilisant les Promesses :

function processMultipleOperations() {
    const promise1 = fetch('https://jsonplaceholder.typicode.com/users/1');
    const promise2 = fetch('https://jsonplaceholder.typicode.com/users/2');
    const promise3 = fetch('https://jsonplaceholder.typicode.com/users/3');

    Promise.all([promise1, promise2, promise3])
        .then(responses => {
            return Promise.all(responses.map(response => response.json()));
        })
        .then(users => {
            console.log('Tous les utilisateurs :', users);
        })
        .catch(error => {
            console.error('Erreur :', error);
        });
}

En utilisant Async/Await :

async function processMultipleOperationsAsync() {
    try {
        const promise1 = fetch('https://jsonplaceholder.typicode.com/users/1');
        const promise2 = fetch('https://jsonplaceholder.typicode.com/users/2');
        const promise3 = fetch('https://jsonplaceholder.typicode.com/users/3');

        const responses = await Promise.all([promise1, promise2, promise3]);
        const users = await Promise.all(responses.map(response => response.json()));

        console.log('Tous les utilisateurs :', users);
    } catch (error) {
        console.error('Erreur :', error);
    }
}

Les deux approches utilisent Promise.all() pour attendre que plusieurs opérations se terminent simultanément.

Quand utiliser les Promesses

Les Promesses sont toujours utiles dans plusieurs scénarios :

1. Travailler avec des API basées sur les Promesses existantes

Les bibliothèques populaires comme Axios, fetch(), et de nombreux modules Node.js retournent des Promesses.

Comment identifier les API basées sur les Promesses :

  • La fonction retourne un objet avec les méthodes .then() et .catch()

  • La documentation mentionne "retourne une Promesse"

  • La fonction ne nécessite pas de paramètre de rappel

De nombreuses bibliothèques et API retournent des Promesses directement :

// La bibliothèque Axios retourne des Promesses
axios.get('/api/users')
    .then(response => response.data)
    .then(users => console.log(users))
    .catch(error => console.error(error));

// L'API fetch() retourne des Promesses
fetch('/api/data')
    .then(response => response.json())
    .then(data => console.log(data));

// Node.js fs.promises retourne des Promesses
import { readFile } from 'fs/promises';
readFile('./config.json', 'utf8')
    .then(data => JSON.parse(data))
    .then(config => console.log(config));

2. Modèles de programmation fonctionnelle

Les Promesses sont des objets immuables qui représentent des valeurs futures, ce qui les rend parfaites pour les approches de programmation fonctionnelle. Elles peuvent être facilement composées, chaînées et transformées sans effets secondaires. La méthode .then() mappe essentiellement la valeur future, de manière similaire à la façon dont Array.map() fonctionne avec les collections.

Les Promesses fonctionnent bien avec les approches de programmation fonctionnelle car elles sont composables et peuvent être facilement passées comme objets de première classe :

// Composition fonctionnelle avec les Promesses
const processUsers = (userIds) => {
    return Promise.all(
        userIds.map(id => fetchUser(id))  // Transformer chaque ID en une Promesse
    )
    .then(users => users.filter(user => user.active))  // Filtrer les utilisateurs actifs
    .then(activeUsers => activeUsers.map(user => user.email));  // Extraire les emails
};

// Approche par pipeline
const createUserPipeline = (userId) => {
    return fetchUser(userId)
        .then(validateUser)
        .then(enrichUserData)
        .then(formatUserResponse)
        .then(logUserActivity);
};

// Composition de plusieurs fonctions retournant des Promesses
const compose = (...fns) => (value) => 
    fns.reduce((promise, fn) => promise.then(fn), Promise.resolve(value));

const userProcessor = compose(
    fetchUser,
    validateUser,
    enrichUserData,
    saveUser
);

3. Création d'utilitaires de Promesses réutilisables

Les utilitaires de Promesses réutilisables sont des fonctions d'assistance qui abstraient les modèles asynchrones courants en composants réutilisables. Ils sont particulièrement utiles pour les préoccupations transversales comme les nouvelles tentatives, les délais d'attente, la limitation du débit et la mise en cache. Ces utilitaires peuvent être utilisés dans différentes parties de votre application sans être liés à une logique métier spécifique.

Quand ils sont utiles :

  • Lorsque vous avez besoin du même modèle asynchrone à plusieurs endroits

  • Pour gérer les scénarios d'échec courants (délais d'attente réseau, nouvelles tentatives)

  • Lorsque vous construisez des middlewares ou des intercepteurs

  • Pour les optimisations de performance comme le regroupement ou le débogage

// Utilitaire de délai d'attente
function timeout(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

// Utilitaire de nouvelle tentative avec délai exponentiel
function retry(fn, retries = 3, delay = 1000) {
    return fn().catch(error => {
        if (retries > 0) {
            console.log(`Nouvelle tentative... ${retries} tentatives restantes`);
            return timeout(delay).then(() => retry(fn, retries - 1, delay * 2));
        }
        throw error;
    });
}

// Utilitaire de limitation de débit
function rateLimit(fn, maxCalls, timeWindow) {
    let calls = [];

    return function(...args) {
        const now = Date.now();
        calls = calls.filter(time => now - time < timeWindow);

        if (calls.length >= maxCalls) {
            const waitTime = timeWindow - (now - calls[0]);
            return timeout(waitTime).then(() => fn.apply(this, args));
        }

        calls.push(now);
        return fn.apply(this, args);
    };
}

// Exemples d'utilisation
const apiCall = () => fetch('/api/data').then(r => r.json());
const resilientApiCall = retry(apiCall, 3);
const rateLimitedApiCall = rateLimit(apiCall, 5, 60000); // 5 appels par minute

Quand utiliser Async/Await

Async/Await est préféré dans la plupart des applications JavaScript modernes. Il présente divers avantages par rapport aux Promesses, tels que :

  1. Lisibilité améliorée : Le code se lit comme du code synchrone, ce qui facilite la compréhension du flux

  2. Meilleur débogage : Les traces de pile sont plus claires et plus faciles à suivre

  3. Gestion des erreurs simplifiée : Un seul bloc try/catch peut gérer plusieurs opérations asynchrones

  4. Réduction de l'imbrication : Élimine la "pyramide de l'enfer" qui peut se produire avec les chaînes de Promesses

  5. Tests plus faciles : Les fonctions asynchrones sont plus faciles à tester et à simuler

  6. Meilleur support IDE : Meilleure complétion automatique et inférence de type dans les éditeurs modernes

Examinons quelques exemples qui démontrent quand async/await serait un meilleur choix.

1. Opérations séquentielles

Lorsque vous devez effectuer des opérations les unes après les autres :

async function processUserData(userId) {
    try {
        const user = await fetchUser(userId);
        const preferences = await fetchUserPreferences(user.id);
        const recommendations = await generateRecommendations(user, preferences);

        return {
            user,
            preferences,
            recommendations
        };
    } catch (error) {
        console.error('Échec du traitement des données utilisateur :', error);
        throw error;
    }
}

Pourquoi c'est mieux que les Promesses : Avec le chaînage des Promesses, vous devriez imbriquer les appels .then() ou retourner des valeurs à travers la chaîne, ce qui rend plus difficile le suivi du flux de données.

2. Gestion des erreurs complexes

Async/await permet d'utiliser la syntaxe familière try/catch et de gérer les erreurs au point exact où elles peuvent se produire. Vous pouvez avoir plusieurs blocs try/catch pour différents scénarios d'erreur, et la logique de gestion des erreurs est co-localisée avec le code qui peut lancer l'erreur.

async function complexOperation(data) {
    try {
        // Premier niveau : erreurs de prétraitement
        const processedData = await preprocessData(data);

        try {
            // Deuxième niveau : erreurs d'opération critique
            const result = await performCriticalOperation(processedData);
            return result;
        } catch (criticalError) {
            // Gérer spécifiquement les erreurs d'opération critique
            console.error('Échec de l\'opération critique :', criticalError);

            // Nous pouvons prendre des décisions en fonction du type d'erreur
            if (criticalError.code === 'TEMPORARY_FAILURE') {
                console.log('Tentative d\'opération de repli...');
                const fallbackResult = await performFallbackOperation(processedData);
                return fallbackResult;
            } else {
                // Relancer si ce n'est pas récupérable
                throw new Error(`Échec critique : ${criticalError.message}`);
            }
        }

    } catch (preprocessError) {
        // Gérer les erreurs de prétraitement différemment
        console.error('Échec du prétraitement :', preprocessError);

        // Nous pouvons inspecter l'erreur et décider comment la gérer
        if (preprocessError.code === 'INVALID_DATA') {
            throw new Error('Données d\'entrée invalides fournies');
        } else {
            throw new Error('Impossible de traiter les données');
        }
    }
}

Explication du code :

  • Le bloc try/catch externe gère les erreurs de prétraitement

  • Le bloc try/catch interne gère spécifiquement les erreurs d'opération critique

  • Chaque gestionnaire d'erreur peut prendre différentes décisions en fonction des types d'erreur

  • Le code montre clairement la stratégie de gestion des erreurs à chaque niveau

  • Vous pouvez facilement ajouter des logs, des métriques ou une logique de récupération à chaque niveau

3. Logique asynchrone conditionnelle

Async/await facilite l'utilisation de flux de contrôle standard (if/else, boucles, instructions switch) avec des opérations asynchrones. Cela est beaucoup plus propre que d'essayer d'implémenter une logique conditionnelle au sein des chaînes de Promesses.

async function smartUserProcess(userId) {
    try {
        // D'abord, obtenir les données de l'utilisateur
        const user = await fetchUser(userId);
        console.log(`Traitement de l'utilisateur : ${user.name} (Premium : ${user.isPremium})`);

        // Prendre des décisions en fonction du résultat asynchrone
        if (user.isPremium) {
            console.log('L\'utilisateur est premium - récupération des fonctionnalités premium');

            // Les utilisateurs premium obtiennent des données supplémentaires
            const premiumData = await fetchPremiumFeatures(user.id);

            // Nous pouvons prendre d'autres décisions en fonction des données premium
            if (premiumData.analyticsEnabled) {
                console.log('Analytics activé - génération des analytics premium');
                const analytics = await generatePremiumAnalytics(user, premiumData);
                return { user, premiumData, analytics };
            } else {
                return { user, premiumData };
            }

        } else {
            console.log('L\'utilisateur est basique - récupération des fonctionnalités basiques');

            // Les utilisateurs basiques reçoivent un traitement différent
            const basicData = await fetchBasicFeatures(user.id);

            // Vérifier si l'utilisateur est éligible aux invites de mise à niveau
            if (basicData.usageLevel > 0.8) {
                console.log('L\'utilisateur a une utilisation élevée - vérification de l\'éligibilité à la mise à niveau');
                const upgradeOffer = await checkUpgradeEligibility(user);
                return { user, basicData, upgradeOffer };
            } else {
                return { user, basicData };
            }
        }
    } catch (error) {
        console.error('Échec du traitement de l\'utilisateur :', error);

        // Même la gestion des erreurs peut être conditionnelle
        if (error.code === 'USER_NOT_FOUND') {
            throw new Error('L\'utilisateur n\'existe pas');
        } else if (error.code === 'NETWORK_ERROR') {
            throw new Error('Problème de connectivité réseau');
        } else {
            throw error;
        }
    }
}

Explication du code :

  • Nous attendons la récupération de l'utilisateur et utilisons immédiatement le résultat dans une instruction if

  • Chaque branche de la conditionnelle peut effectuer différentes opérations asynchrones

  • Nous pouvons imbriquer des conditions naturellement (comme vérifier analyticsEnabled)

  • Le flux de contrôle standard fonctionne de manière transparente avec les opérations asynchrones

  • La gestion des erreurs peut également être conditionnelle en fonction des types d'erreur

  • Le code se lit comme du code synchrone mais gère correctement les opérations asynchrones

Considérations de performance

Comprendre les implications de performance des différents modèles asynchrones est crucial pour construire des applications JavaScript efficaces. La principale considération de performance est de savoir si vos opérations asynchrones peuvent s'exécuter en parallèle ou doivent être exécutées séquentiellement.

Lorsque vous travaillez avec plusieurs opérations asynchrones, vous avez deux principaux modèles d'exécution : séquentiel (l'un après l'autre) et parallèle (plusieurs opérations en même temps). Le choix entre ces modèles peut avoir un impact significatif sur les performances et l'expérience utilisateur de votre application.

Exécution séquentielle vs parallèle

Exécution séquentielle (plus lente mais parfois nécessaire) :

L'exécution séquentielle signifie attendre que chaque opération se termine avant de commencer la suivante. Cela est plus lent mais nécessaire lorsque les opérations dépendent les unes des autres.

// Cela prend ~3 secondes au total (1 + 1 + 1)
async function sequentialOperations() {
    console.time('Opérations séquentielles');

    const result1 = await operation1(); // 1 seconde - doit se terminer en premier
    console.log('Opération 1 terminée :', result1);

    const result2 = await operation2(); // 1 seconde - commence après operation1
    console.log('Opération 2 terminée :', result2);

    const result3 = await operation3(); // 1 seconde - commence après operation2
    console.log('Opération 3 terminée :', result3);

    console.timeEnd('Opérations séquentielles');
    return [result1, result2, result3];
}

Utiliser l'exécution séquentielle lorsque :

  • Chaque opération dépend du résultat de la précédente

  • Vous devez traiter les résultats dans un ordre spécifique

  • Les opérations doivent être limitées en débit (par exemple, les appels API avec des limites de débit)

  • Vous souhaitez éviter de surcharger les services externes

Exécution parallèle (plus rapide lorsque possible) :

L'exécution parallèle signifie démarrer toutes les opérations en même temps et attendre que toutes se terminent. Cela est beaucoup plus rapide lorsque les opérations sont indépendantes.

// Cela prend ~1 seconde au total (toutes les opérations s'exécutent simultanément)
async function parallelOperations() {
    console.time('Opérations parallèles');

    // Démarrer toutes les opérations immédiatement - elles s'exécutent simultanément
    const promise1 = operation1(); // démarre immédiatement
    const promise2 = operation2(); // démarre immédiatement  
    const promise3 = operation3(); // démarre immédiatement

    console.log('Toutes les opérations démarrées, en attente de la fin...');

    // Attendre que toutes les opérations se terminent
    const [result1, result2, result3] = await Promise.all([
        promise1,
        promise2,
        promise3
    ]);

    console.log('Toutes les opérations terminées');
    console.timeEnd('Opérations parallèles');
    return [result1, result2, result3];
}

Utiliser l'exécution parallèle lorsque :

  • Les opérations sont indépendantes les unes des autres

  • Vous souhaitez minimiser le temps d'exécution total

  • Traiter des opérations d'E/S (lectures de fichiers, appels API, requêtes de base de données)

  • L'ordre de terminaison n'a pas d'importance

Exemple avancé - Approche mixte :

Parfois, vous avez besoin d'une combinaison des deux approches :

javascriptasync function mixedApproach(userIds) {
    console.time('Approche mixte');

    // Étape 1 : Récupérer tous les utilisateurs en parallèle (ils sont indépendants)
    console.log('Récupération des utilisateurs en parallèle...');
    const users = await Promise.all(
        userIds.map(id => fetchUser(id))
    );

    // Étape 2 : Traiter chaque utilisateur séquentiellement (pour éviter de surcharger le service de recommandation)
    console.log('Traitement des utilisateurs séquentiellement...');
    const results = [];
    for (const user of users) {
        const preferences = await fetchUserPreferences(user.id);
        const recommendations = await generateRecommendations(user, preferences);
        results.push({ user, preferences, recommendations });

        // Petit délai pour être respectueux de l'API
        await new Promise(resolve => setTimeout(resolve, 100));
    }

    console.timeEnd('Approche mixte');
    return results;
}

Utilisez Promise.all() lorsque les opérations peuvent s'exécuter indépendamment et simultanément.

Modèles de gestion des erreurs

Une gestion appropriée des erreurs est cruciale pour construire des applications robustes. Différents modèles asynchrones offrent différentes approches de gestion des erreurs, chacune avec ses propres avantages et cas d'utilisation.

La gestion des erreurs en JavaScript asynchrone peut être difficile car les erreurs peuvent se produire à différents points du flux d'exécution. Comprendre comment attraper, gérer et récupérer correctement les erreurs dans les modèles Promise et async/await est essentiel pour construire des applications fiables.

Gestion des erreurs des Promesses :

Avec les Promesses, les erreurs sont gérées en utilisant la méthode .catch(). Cette approche fournit un contrôle précis sur la gestion des erreurs à différents points de la chaîne.

function promiseErrorHandling() {
    return fetchData()
        .then(data => {
            console.log('Données récupérées avec succès');
            return processData(data);
        })
        .then(result => {
            console.log('Données traitées avec succès');
            return saveResult(result);
        })
        .then(savedResult => {
            console.log('Résultat sauvegardé avec succès');
            return savedResult;
        })
        .catch(error => {
            console.error('Erreur survenue dans la chaîne :', error);

            // Gérer différents types d'erreurs
            if (error.name === 'NetworkError') {
                console.log('Problème réseau détecté, tentative de nouvelle tentative...');
                return retryOperation();
            } else if (error.name === 'ValidationError') {
                console.log('Échec de la validation des données :', error.message);
                return handleValidationError(error);
            } else if (error.name === 'StorageError') {
                console.log('Échec de l\'opération de stockage, utilisation de la solution de repli');
                return saveToFallbackStorage(error.data);
            } else {
                console.log('Type d\'erreur inconnu :', error);
                throw error; // Relancer si nous ne pouvons pas la gérer
            }
        });
}

Voici ce qui se passe dans ce code :

  • La méthode .catch() attrape toute erreur qui se produit dans toute la chaîne

  • Vous pouvez inspecter l'objet d'erreur pour déterminer la réponse appropriée

  • Retourner une valeur de .catch() récupère de l'erreur

  • Lancer une erreur ou ne rien retourner propage l'erreur plus loin

  • Le gestionnaire d'erreur a accès au contexte d'erreur original

Gestion des erreurs Async/Await

Avec async/await, les erreurs sont gérées en utilisant des blocs try/catch, qui fournissent des modèles de gestion des erreurs plus familiers et flexibles.

async function asyncAwaitErrorHandling() {
    try {
        console.log('Début du traitement des données...');

        const data = await fetchData();
        console.log('Données récupérées avec succès');

        const result = await processData(data);
        console.log('Données traitées avec succès');

        const savedResult = await saveResult(result);
        console.log('Résultat sauvegardé avec succès');

        return savedResult;

    } catch (error) {
        console.error('Erreur survenue pendant le traitement :', error);

        // Gérer différents types d'erreurs avec une logique plus complexe
        if (error.name === 'NetworkError') {
            console.log('Problème réseau détecté');

            // Nous pouvons utiliser des opérations asynchrones dans la gestion des erreurs
            const retryCount = await getRetryCount();
            if (retryCount < 3) {
                console.log(`Nouvelle tentative... (tentative ${retryCount + 1})`);
                await incrementRetryCount();
                return await retryOperation();
            } else {
                console.log('Nombre maximal de tentatives atteint, passage en mode hors ligne');
                return await switchToOfflineMode();
            }

        } else if (error.name === 'ValidationError') {
            console.log('Échec de la validation des données :', error.message);

            // Gérer les erreurs de validation avec un retour utilisateur
            await logValidationError(error);
            return handleValidationError(error);

        } else if (error.name === 'StorageError') {
            console.log('Échec de l\'opération de stockage');

            // Essayer plusieurs options de repli
            try {
                return await saveToFallbackStorage(error.data);
            } catch (fallbackError) {
                console.log('Le stockage de repli a également échoué, utilisation du cache');
                return await saveToCache(error.data);
            }

        } else {
            console.log('Type d\'erreur inconnu, journalisation pour analyse');
            await logErrorForAnalysis(error);
            throw error; // Relancer les erreurs inconnues
        }
    }
}

Voici ce qui se passe dans le code :

  • Le bloc try/catch gère toutes les erreurs dans la fonction asynchrone

  • Vous pouvez utiliser await à l'intérieur des blocs catch pour les opérations de récupération d'erreur

  • Plusieurs blocs try/catch imbriqués peuvent gérer différents scénarios

  • Le code de gestion des erreurs peut être aussi complexe que nécessaire, y compris des boucles et des conditions

  • La logique de gestion des erreurs est co-localisée avec le code qui peut lancer l'erreur

Modèle d'erreur avancé - Gestion spécifique des erreurs :

javascriptasync function advancedErrorHandling(userId) {
    try {
        const user = await fetchUser(userId);

        try {
            const sensitiveData = await fetchSensitiveData(user.id);
            return { user, sensitiveData };
        } catch (sensitiveError) {
            // Gérer spécifiquement les erreurs de données sensibles
            if (sensitiveError.code === 'PERMISSION_DENIED') {
                console.log('L\'utilisateur manque de permission pour les données sensibles');
                return { user, sensitiveData: null, reason: 'permission_denied' };
            } else {
                // Pour d'autres erreurs de données sensibles, nous voulons toujours retourner l'utilisateur
                console.warn('Données sensibles indisponibles :', sensitiveError.message);
                return { user, sensitiveData: null, reason: 'data_unavailable' };
            }
        }

    } catch (userError) {
        // Gérer les erreurs de récupération de l'utilisateur
        if (userError.code === 'USER_NOT_FOUND') {
            throw new Error(`L\'utilisateur ${userId} n\'existe pas`);
        } else if (userError.code === 'NETWORK_ERROR') {
            throw new Error('Impossible de se connecter au service utilisateur');
        } else {
            throw new Error(`Échec de la récupération de l\'utilisateur : ${userError.message}`);
        }
    }
}

Bonnes pratiques

Suivre ces bonnes pratiques vous aidera à écrire du code JavaScript asynchrone plus fiable, maintenable et performant.

Toujours gérer les erreurs

L'une des erreurs les plus courantes en JavaScript asynchrone est d'oublier de gérer les erreurs. Lorsqu'une opération asynchrone échoue sans une gestion appropriée des erreurs, cela peut entraîner des rejets de promesses non gérés, des plantages d'application ou des échecs silencieux difficiles à déboguer.

Ne faites pas cela :

javascript// Gestion des erreurs manquante - c'est dangereux !
async function badExample() {
    const data = await fetchData(); // Et si cela échoue ?
    const processed = await processData(data); // Et si cela échoue ?
    return await saveData(processed); // Et si cela échoue ?
}

// Utilisation - l'utilisateur ne sait pas si quelque chose a mal tourné
const result = await badExample();
console.log(result); // Peut être indéfini ou provoquer un plantage

Pourquoi c'est problématique :

  • Si fetchData() échoue, toute la fonction plante

  • L'appelant n'a aucun moyen de savoir ce qui a mal tourné

  • En production, cela pourrait entraîner une mauvaise expérience utilisateur

  • Le débogage devient beaucoup plus difficile sans contexte d'erreur approprié

Faites cela à la place :

javascript// Gestion appropriée des erreurs avec contexte et récupération
async function goodExample() {
    try {
        console.log('Début du traitement des données...');

        const data = await fetchData();
        console.log('Données récupérées avec succès');

        const processed = await processData(data);
        console.log('Données traitées avec succès');

        const result = await saveData(processed);
        console.log('Données sauvegardées avec succès');

        return result;
    } catch (error) {
        // Journaliser l'erreur avec contexte
        console.error('Échec du traitement des données :', {
            error: error.message,
            step: error.step || 'unknown',
            timestamp: new Date().toISOString()
        });

        // Relancer avec plus de contexte ou gérer de manière appropriée
        throw new Error(`Échec du traitement des données : ${error.message}`);
    }
}

// Utilisation - l'appelant sait comment gérer les échecs
try {
    const result = await goodExample();
    console.log('Succès :', result);
} catch (error) {
    console.error('Échec du traitement des données :', error.message);
    // Gérer l'erreur de manière appropriée pour votre application
    showErrorToUser('Échec du traitement des données. Veuillez réessayer.');
}

Pourquoi c'est mieux :

  • Chaque opération asynchrone est enveloppée dans try/catch

  • Les erreurs sont journalisées avec un contexte utile

  • L'appelant reçoit des messages d'erreur significatifs

  • L'application peut gérer les échecs de manière élégante

Utiliser Promise.all() pour les opérations indépendantes

Un anti-modèle de performance courant consiste à faire en sorte que des opérations asynchrones indépendantes s'exécutent séquentiellement alors qu'elles pourraient s'exécuter en parallèle. Cela augmente inutilement le temps d'exécution total.

Ne faites pas cela :

javascript// Séquentiel alors que cela pourrait être parallèle - c'est inefficace !
async function inefficient() {
    console.time('Approche inefficace');

    // Ces opérations sont indépendantes mais s'exécutent les unes après les autres
    const user = await fetchUser();        // 500ms
    const posts = await fetchPosts();      // 300ms  
    const comments = await fetchComments(); // 400ms
    // Temps total : ~1200ms

    console.timeEnd('Approche inefficace');
    return { user, posts, comments };
}

Pourquoi c'est problématique :

  • Chaque opération attend que la précédente se termine

  • Le temps d'exécution total est la somme de tous les temps individuels

  • Les ressources réseau sont sous-utilisées

  • Les utilisateurs subissent des retards inutiles

Cela est particulièrement courant lors de la récupération de données pour un tableau de bord ou une page qui a besoin de plusieurs morceaux d'information. Les développeurs écrivent souvent le code séquentiellement sans considérer que ces opérations peuvent s'exécuter simultanément.

Faites cela à la place :

javascript// Exécution parallèle - beaucoup plus efficace !
async function efficient() {
    console.time('Approche efficace');

    // Démarrer toutes les opérations simultanément
    const userPromise = fetchUser();        // démarre immédiatement
    const postsPromise = fetchPosts();      // démarre immédiatement
    const commentsPromise = fetchComments(); // démarre immédiatement

    // Attendre que toutes se terminent
    const [user, posts, comments] = await Promise.all([
        userPromise,
        postsPromise, 
        commentsPromise
    ]);
    // Temps total : ~500ms (opération individuelle la plus longue)

    console.timeEnd('Approche efficace');
    return { user, posts, comments };
}

Exemple avancé avec gestion des erreurs :

javascriptasync function efficientWithErrorHandling() {
    try {
        console.log('Début de la récupération parallèle des données...');

        // Démarrer toutes les opérations et gérer les échecs individuels
        const results = await Promise.allSettled([
            fetchUser().catch(error => ({ error: error.message, type: 'user' })),
            fetchPosts().catch(error => ({ error: error.message, type: 'posts' })),
            fetchComments().catch(error => ({ error: error.message, type: 'comments' }))
        ]);

        // Traiter les résultats et gérer les échecs partiels
        const [userResult, postsResult, commentsResult] = results;

        return {
            user: userResult.status === 'fulfilled' ? userResult.value : null,
            posts: postsResult.status === 'fulfilled' ? postsResult.value : [],
            comments: commentsResult.status === 'fulfilled' ? commentsResult.value : [],
            errors: results
                .filter(result => result.status === 'rejected' || result.value?.error)
                .map(result => result.reason || result.value?.error)
        };

    } catch (error) {
        console.error('Échec de la récupération des données :', error);
        throw error;
    }
}

Pourquoi c'est mieux :

  • Les opérations s'exécutent en parallèle, réduisant le temps d'exécution total

  • Les ressources réseau et CPU sont utilisées plus efficacement

  • L'application semble plus réactive pour les utilisateurs

  • Peut gérer les échecs partiels de manière élégante

Ne mélangez pas les modèles inutilement

Mélanger les chaînes de Promesses avec async/await dans la même fonction crée un code incohérent qui est plus difficile à lire, déboguer et maintenir. Cela peut également entraîner des bugs subtils et rendre le flux de code moins prévisible.

Évitez cela :

javascript// Mélanger async/await avec .then() - c'est confus !
async function mixedPattern() {
    const data = await fetchData();

    // Soudainement passer au style de chaîne de Promesses
    return processData(data).then(result => {
        console.log('Traitement terminé');
        return saveResult(result);
    }).then(savedResult => {
        return savedResult.id;
    }).catch(error => {
        console.error('Erreur dans la chaîne de Promesses :', error);
        throw error;
    });
}

Pourquoi c'est problématique :

  • Modèles de gestion des erreurs incohérents (try/catch vs .catch())

  • Plus difficile à suivre le flux d'exécution

  • Expériences de débogage différentes

  • Plus d'opportunités pour les bugs

  • Les membres de l'équipe doivent comprendre plusieurs modèles

Cela se produit souvent lorsque les développeurs travaillent avec du code existant basé sur les Promesses et essaient d'introduire progressivement async/await sans convertir complètement la fonction.

Faites cela à la place :

javascript// Async/await cohérent - beaucoup plus clair !
async function consistentPattern() {
    try {
        const data = await fetchData();
        console.log('Données récupérées');

        const result = await processData(data);
        console.log('Traitement terminé');

        const savedResult = await saveResult(result);
        console.log('Résultat sauvegardé');

        return savedResult.id;
    } catch (error) {
        console.error('Erreur dans la fonction asynchrone :', error);
        throw error;
    }
}

Approche alternative cohérente avec les Promesses :

javascript// Si vous préférez les Promesses, soyez cohérent avec cela aussi
function consistentPromisePattern() {
    return fetchData()
        .then(data => {
            console.log('Données récupérées');
            return processData(data);
        })
        .then(result => {
            console.log('Traitement terminé');
            return saveResult(result);
        })
        .then(savedResult => {
            console.log('Résultat sauvegardé');
            return savedResult.id;
        })
        .catch(error => {
            console.error('Erreur dans la chaîne de Promesses :', error);
            throw error;
        });
}

Utiliser des noms de variables descriptifs dans les fonctions asynchrones

javascript// Mauvaise nomination
async function process(id) {
    const d = await fetch(id);
    const r = await transform(d);
    return r;
}

// Meilleure nomination
async function processUserProfile(userId) {
    const userData = await fetchUserData(userId);
    const transformedProfile = await transformUserData(userData);
    return transformedProfile;
}

Gérer les délais d'attente pour les opérations longues

javascript// Ajouter un wrapper de délai d'attente pour la fiabilité
function withTimeout(promise, timeoutMs) {
    const timeout = new Promise((_, reject) => {
        setTimeout(() => reject(new Error('Délai d\'attente de l\'opération dépassé')), timeoutMs);
    });

    return Promise.race([promise, timeout]);
}

// Utilisation
async function reliableDataFetch(userId) {
    try {
        // Délai d'attente après 5 secondes
        const userData = await withTimeout(fetchUserData(userId), 5000);
        return userData;
    } catch (error) {
        if (error.message === 'Délai d\'attente de l\'opération dépassé') {
            console.log('Requête dépassée, utilisation des données en cache');
            return getCachedUserData(userId);
        }
        throw error;
    }
}

Faire le bon choix

Voici un cadre de décision pour vous aider à choisir :

Choisissez les Promesses lorsque :

  • Vous travaillez avec des bibliothèques qui retournent des Promesses

  • Vous écrivez du code de style programmation fonctionnelle

  • Vous créez des fonctions utilitaires que d'autres codes vont chaîner

  • Vous avez besoin d'un contrôle précis sur le comportement des Promesses

  • Vous travaillez avec des bases de code existantes basées sur les Promesses

Choisissez Async/Await lorsque :

  • Vous écrivez un nouveau code d'application

  • Vous avez besoin d'opérations séquentielles avec un flux clair

  • Une gestion des erreurs complexe est requise

  • Vous travaillez avec une logique asynchrone conditionnelle

  • La lisibilité du code est une priorité

  • Vous construisez des applications JavaScript modernes

Considérez les deux lorsque :

  • Vous utilisez Promise.all(), Promise.race(), ou Promise.allSettled()

  • Vous construisez des flux asynchrones complexes

  • Vous avez besoin à la fois de la puissance des Promesses et de la lisibilité d'Async/Await

Conclusion

Les Promesses et Async/Await sont tous deux des outils puissants pour gérer les opérations asynchrones en JavaScript. Les Promesses offrent de la flexibilité et un contrôle précis, tandis qu'Async/Await propose un code plus propre et plus lisible, plus facile à déboguer et à maintenir.

Dans le développement JavaScript moderne, Async/Await est généralement préféré pour le code d'application en raison de sa lisibilité et de sa facilité d'utilisation. Cependant, comprendre les Promesses reste crucial puisque Async/Await est construit par-dessus, et de nombreuses bibliothèques et API utilisent encore directement les Promesses.

L'essentiel est de comprendre les deux approches et de choisir celle qui convient le mieux à votre cas d'utilisation spécifique, aux préférences de l'équipe et aux exigences du projet. N'oubliez pas que vous pouvez toujours mélanger les deux approches lorsque cela a du sens - utilisez Async/Await pour la logique principale de votre application et les Promesses pour les fonctions utilitaires et les intégrations de bibliothèques.

En maîtrisant bien les deux techniques, vous serez bien équipé pour relever tout défi de programmation asynchrone en JavaScript.