Explication du fonctionnement des Promesses et implémentation d'un objet Promise (conformément à la spécification Promise/A+)

  1. Qu'est-ce qu'une Promise ?

Une Promise est un concept clé de la programmation asynchrone en JavaScript, servant d'abstraction pour gérer les opérations asynchrones. Elle représente l'une des solutions les plus populaires pour la programmation asynchrone en JavaScript.

  1. Différentes approches courantes de programmation asynchrone

  • Fonctions de rappel
  • Écouteurs d'événements
  • Publier/abonner
  • Objets Promise

Illustrons avec les fonctions de rappel

(1) Lorsque nous utilisons jQuery pour effectuer une requête AJAX, nous récupérons les données via une fonction de rappel.

$.get(url, (data) => {
    console.log(data)
)

(2) Si nous devons envoyer plusieurs requêtes asynchrones dépendantes les unes des autres, nous devons utiliser une approche imbriquée, générant ainsi le "callback hell".

$.get(url, data1 => {
    console.log(data1)
    $.get(data1.url, data2 => {
        console.log(data1)
    })
})

Cette approche entraîne des problèmes majeurs lorsqu'on gère plusieurs opérations asynchrones :

  • L'ordre d'écriture du code ne correspond pas à l'ordre d'exécution, rendant la lecture et la maintenance difficiles.
  • Chaque modification de l'ordre des opérations asynchrones nécessite des changements importants dans le code.
  • Les fonctions de rappel sont généralement anonymes, ce qui complique le débogage.
  • Les fonctions de rappel sont appelées par des bibliothèques tierces (comme AJAX), ce qui entraîne une inversion de contrôle.

Utilisation des Promises pour gérer plusieurs requêtes liées

(1) Les Promises permettent de résoudre le problème du "callback hell" de manière plus intuitive.

const fetchResource = url => { 
    return new Promise((succeed, fail) => {
        $.get(url, data => {
            succeed(data)
        });
    })
};

// Récupération de data1
fetchResource(url).then(data1 => {
    return fetchResource(data1.url);   
}).then(data2 => {
    return fetchResource(data2.url);
}).then(data3 => {
    console.log(data3);
}).catch(err => throw new Error(err));

(2) Dans les frameworks comme Vue ou React, Axios ou Fetch sont souvent utilisés pour les requêtes, et ils intègrent nativement les API Promise.

import axios from 'axios';
axios.get(url).then(data => {
   console.log(data)
})

Axios est une bibliothèque HTTP basée sur les Promises, utilisable dans les navigateurs et Node.js.

  1. Utilisation des Promises

Une Promise est un constructeur. Lorsqu'on l'instancie avec new Promise(), elle retourne un objet Promise qui prend une fonction exécuteur en paramètre. Cette fonction exécuteur reçoit deux paramètres de type fonction : succeed et fail.

const promesse = new Promise((succeed, fail) => {
       // Traitement asynchrone
       // Après traitement, appel de succeed ou fail
});

Une Promise fonctionne comme une machine à états

Les trois états d'une Promise :

  • pending
  • fulfilled
  • rejected

(1) L'état initial d'une Promise est pending.

(2) Lors de l'appel de succeed (succès), l'état passe de pending à fulfilled.

(3) Lors de l'appel de fail (échec), l'état passe de pending à rejected.

Notez que l'état d'une Promise ne peut passer qu'une fois de pending vers fulfilled ou rejected, et ne peut plus être modifié après cela.

Méthodes des objets Promise

(1) La méthode then permet d'enregistrer des callback pour les états succeed (succès) et fail (échec).

// onSucceed est utilisé pour recevoir la valeur de la Promise réussie
// onFail est utilisé pour recevoir la raison de l'échec de la Promise
promise.then(onSucceed, onFail);

Notez que la méthode then est exécutée de manière asynchrone.

(2) En cas de succès (succeed), onSucceed est appelé.

const promesse = new Promise((succeed, fail) => {
   succeed('fulfilled'); // État passé de pending à fulfilled
});
promesse.then(result => { // onSucceed
    console.log(result); // 'fulfilled' 
}, reason => { // onFail ne sera pas appelé
    
})

(3) En cas d'échec (fail), onFail est appelé.

const promesse = new Promise((succeed, fail) => {
   fail('rejected'); // État passé de pending à rejected
});
promesse.then(result => { // onSucceed ne sera pas appelé
  
}, reason => { // onFail 
    console.log(rejected); // 'rejected'
})

(4) promesse.catch

Permet de capturer les erreurs levées dans les méthodes then lors de la chaîne de promesses.

promise.catch(onFail)
équivalent à
promise.then(null, onFail);

// Notez que
// onFail ne peut pas capturer les erreurs levées dans onSucceed
promise.then(onSucceed, onFail); 

// Peut s'écrire comme :
promise.then(onSucceed)
       .catch(onFail); 

Chaîne de promesses

La méthode then retourne toujours une nouvelle Promise, permettant ainsi une écriture en chaîne.

function tacheA() {
    console.log("Tâche A");
}
function tacheB() {
    console.log("Tâche B");
}
function onFail(error) {
    console.log("Catch Error: A or B", error);
}

var promesse = Promise.resolve();
promesse
    .then(tacheA)
    .then(tacheB)
    .catch(onFail) // Capture les erreurs des méthodes then précédentes

Méthodes statiques de Promise

(1) Promise.resolve retourne une Promise dans l'état fulfilled.

Promise.resolve('hello').then(function(value){
    console.log(value);
});

Promise.resolve('hello');
// Équivalent à
const promesse = new Promise(succeed => {
   succeed('hello');
});

(2) Promise.reject retourne une Promise dans l'état rejected.

Promise.reject(24);
new Promise((succeed, fail) => {
   fail(24);
});

(3) Promise.all reçoit un tableau de Promises comme argument.

Seul le cas où toutes les Promises sont résolues déclenche la résolution. Cela est utile pour gérer plusieurs opérations asynchrones parallèles.

const p1 = new Promise((succeed, fail) => {
    succeed(1);
});

const p2 = new Promise((succeed, fail) => {
    succeed(2);
});

const p3 = new Promise((succeed, fail) => {
    fail(3);
});

Promise.all([p1, p2, p3]).then(data => { 
    console.log(data); // [1, 2, 3] l'ordre est identique à celui du tableau des instances Promise
}, err => {
    console.log(err);
});

(4) Promise.race reçoit un tableau de Promises comme arguement.

Promise.race continue le traitement dès qu'une Promise atteint l'état fulfilled ou rejected.

function timerPromisefy(delay) {
    return new Promise(function (succeed, fail) {
        setTimeout(function () {
            succeed(delay);
        }, delay);
    });
}
var startDate = Date.now();

Promise.race([
    timerPromisefy(10),
    timerPromisefy(20),
    timerPromisefy(30)
]).then(function (values) {
    console.log(values); // 10
});

  1. Implémentation d'une Promise

/**
 * Implémentation d'une Promise conforme à la spécification Promise/A+
 * Traduction de la spécification Promise/A+ :
 * https://malcolmyu.github.io/2015/06/12/Promises-A-Plus/#note-4
 */

// trois états de la Promise
const EN_ATTENTE = "en_attente";
const ACHIEVE = "achieve";
const REJETE = "rejete";

function Promise(executeur) {
    let instance = this; // stocke l'instance actuelle de Promise
    instance.etat = EN_ATTENTE; // état initial
    instance.valeur = undefined; // valeur reçue en cas de succès
    instance.raison = undefined; // raison du rejet
    instance.callbacksSucces = []; // stocke les fonctions callback pour le succès
    instance.callbacksEchec = []; // stocke les fonctions callback pour l'échec

    function succeed(valeur) { // valeur reçue en cas de succès
        if(valeur instanceof Promise) {
            return valeur.then(succeed, fail);
        }

        // Pourquoi utiliser setTimeout dans succeed ?
        // Selon la spécification 2.2.4, les fonctions onSucceed et onFail doivent uniquement s'exécuter lorsque le contexte d'exécution contient uniquement du code de plateforme.
        // Note : le code de plateforme désigne le moteur, l'environnement et le code d'implémentation de la Promise. En pratique, cela garantit que les fonctions onSucceed et onFail s'exécutent de manière asynchrone, dans une nouvelle pile d'exécution après la phase de boucle d'événement actuelle.

        setTimeout(() => {
            // Appelle la fonction callback associée à succeed
            if (instance.etat === EN_ATTENTE) {
                // Passage uniquement de l'état en_attente à achieve (évite d'appeler plusieurs fois succeed ou fail)
                instance.etat = ACHIEVE;
                instance.valeur = valeur;
                instance.callbacksSucces.forEach(cb => cb(instance.valeur));
            }
        });
    }

    function fail(raison) { // raison du rejet
        setTimeout(() => {
            // Appelle la fonction callback associée à fail
            if (instance.etat === EN_ATTENTE) {
                // Passage uniquement de l'état en_attente à rejete (évite d'appeler plusieurs fois succeed ou fail)
                instance.etat = REJETE;
                instance.raison = raison;
                instance.callbacksEchec.forEach(cb => cb(instance.raison));
            }
        });
    }

    // Capture les exceptions levées dans l'exécuteur
    // new Promise((succeed, fail) => {
    //     throw new Error('erreur dans l'exécuteur')
    // })
    try {
        executeur(succeed, fail);
    } catch (e) {
        fail(e);
    }
}

/**
 * Valeurs possibles dans succeed :
 * 1. Valeur ordinaire
 * 2. Objet Promise
 * 3. Objet/fonction thenable
 */

/**
 * Amélioration de la fonction succeed pour gérer les différentes valeurs possibles
 * @param  {promise} nouvellePromise promise1.then() retourne une nouvelle Promise
 * @param  {[type]} x         valeur retournée par onSucceed de promise1
 * @param  {[type]} succeed   méthode succeed de nouvellePromise
 * @param  {[type]} fail      méthode fail de nouvellePromise
 */
function gererResolution(nouvellePromise, x, succeed, fail) {
    if (nouvellePromise === x) {  // Si la valeur retournée par onSucceed est la même que nouvellePromise, cela entraîne une référence circulaire
        return fail(new TypeError('Référence circulaire'));
    }

    let appele = false; // évite les appels multiples
    // Si x est un objet Promise (la vérification ci-dessous est redondante avec la vérification du thenable, donc optionnelle)
    if (x instanceof Promise) { // obtient sa valeur finale et continue la résolution
        if (x.etat === EN_ATTENTE) { // si l'état est en attente, attend que x soit exécuté ou rejeté et obtient la valeur y
            x.then(y => {
                gererResolution(nouvellePromise, y, succeed, fail);
            }, raison => {
                fail(raison);
            });
        } else { // si x est déjà exécuté ou rejeté (valeur résolue), transmet la même valeur
            x.then(succeed, fail);
        }
        // Si x est un objet ou une fonction
    } else if (x != null && ((typeof x === 'object') || (typeof x === 'function'))) {
        try { // vérifie si x est un objet thenable (possède une méthode then)
            let then = x.then;
            if (typeof then === 'function') {
                then.call(x, y => {
                    if(appele) return;
                    appele = true;
                    gererResolution(nouvellePromise, y, succeed, fail);
                }, raison => {
                    if(appele) return;
                    appele = true;
                    fail(raison);
                })
            } else { // c'est un objet ordinaire ou une fonction
                succeed(x);
            }
        } catch(e) {
            if(appele) return;
            appele = true;
            fail(e);
        }
    } else {
        succeed(x);
    }
}

/**
 * Enregistre les fonctions callback associées aux états achieve/rejete
 * @param  {function} onSucceed fonction exécutée en cas d'achèvement
 * @param  {function} onFail    fonction exécutée en cas de rejet
 * @return {function} nouvellePromise  retourne une nouvelle Promise
 */
Promise.prototype.then = function(onSucceed, onFail) {
    const instance = this;
    let nouvellePromise;
    // Gestion des valeurs par défaut pour garantir leur utilisation ultérieure
    onSucceed =
        typeof onSucceed === "function" ? onSucceed : valeur => valeur;
    onFail =
        typeof onFail === "function" ? onFail : raison => {
            throw raison;
        };

    // Pourquoi ajouter setTimeout dans les états achieve/rejete ?
    // Première raison : selon la spécification 2.2.4, assurez-vous que onSucceed et onFail s'exécutent de manière asynchrone (et doivent s'exécuter dans une nouvelle pile d'exécution après la phase de boucle d'événement actuelle). Donc, ajouter setTimeout dans succeed.
    // Deuxième raison : selon la spécification 2.2.6, un Promise peut avoir plusieurs appels then. (Lorsque then est appelé plusieurs fois sur le même Promise, si son état est déjà achieve ou rejete, il s'exécute dans la logique ci-dessous). Donc, assurez-vous que les fonctions onSucceed/onFail s'exécutent de manière asynchrone même après que l'état est achieve/rejete.

    // En résumé, tout cela vise à assurer l'exécution asynchrone de then, donc la garantie d'exécution asynchrone de onSucceed/onFail.

    // Par exemple, dans le cas suivant, plusieurs appels à p1.then :
    // p1.then((valeur) => { // ici, p1.etat est passé de en_attente à achieve
    //     console.log(valeur); // succeed
    //     // console.log(p1.etat); // achieve
    //     p1.then(valeur => { // appel à p1.then à nouveau, ici l'état est already achieve, donc il suit la logique de achieve ci-dessous. Nous devons également assurer que l'exécution de onSucceed se fait de manière asynchrone
    //         console.log(valeur); // 'succeed'
    //     });
    //     console.log('Code synchronisé actuel');
    // })
    // console.log('Code synchronisé global');
    //

    if (instance.etat === ACHIEVE) { // état de succès
        return nouvellePromise = new Promise((succeed, fail) => {
            setTimeout(() => {
                try{
                    let x = onSucceed(instance.valeur);
                    gererResolution(nouvellePromise, x, succeed, fail); // nouvellePromise utilise la valeur retournée par onSucceed
                } catch(e) {
                    fail(e); // capture les exceptions levées dans onSucceed
                }
            });
        })
    }

    if (instance.etat === REJETE) { // état de rejet
        return nouvellePromise = new Promise((succeed, fail) => {
            setTimeout(() => {
                try {
                    let x = onFail(instance.raison);
                    gererResolution(nouvellePromise, x, succeed, fail);
                } catch(e) {
                    fail(e);
                }
            });
        });
    }

    if (instance.etat === EN_ATTENTE) { // état d'attente
        // Lors de l'appel asynchrone de succeed/fail, stocke les callbacks onSucceed/onFail dans les collections
        return nouvellePromise = new Promise((succeed, fail) => {
            instance.callbacksSucces.push((valeur) => {
                try {
                    let x = onSucceed(valeur);
                    gererResolution(nouvellePromise, x, succeed, fail);
                } catch(e) {
                    fail(e);
                }
            });
            instance.callbacksEchec.push((raison) => {
                try {
                    let x = onFail(raison);
                    gererResolution(nouvellePromise, x, succeed, fail);
                } catch(e) {
                    fail(e);
                }
            });
        });
    }
};

/**
 * Promise.all pour le traitement parallèle des Promises
 * Paramètre : tableau d'objets Promise
 * Retourne : un objet Promise
 * Se résout uniquement lorsque toutes les Promises du tableau sont résolues.
 */
Promise.all = function(promesses) {
    return new Promise((succeed, fail) => {
        let complet = gen(promesses.length, succeed);
        promesses.forEach((promesse, index) => {
            promesse.then((valeur) => {
                complet(index, valeur)
            }, fail)
        })
    })
}

function gen(length, succeed) {
    let compteur = 0;
    let valeurs = [];
    return function(i, valeur) {
        valeurs[i] = valeur;
        if (++compteur === length) {
            console.log(valeurs);
            succeed(valeurs);
        }
    }
}

/**
 * Promise.race
 * Paramètre : tableau d'objets Promise
 * Retourne : un objet Promise
 * Continue le traitement dès qu'une Promise atteint l'état achieve ou rejete (selon laquelle est la plus rapide)
 */
Promise.race = function(promesses) {
    return new Promise((succeed, fail) => {
        promesses.forEach((promesse, index) => {
           promesse.then(succeed, fail);
        });
    });
}

// Pour capturer les exceptions levées lors de l'utilisation de la chaîne de Promise
Promise.prototype.catch = function(onFail) {
    return this.then(null, onFail);
}

Promise.resolve = function (valeur) {
    return new Promise(succeed => {
        succeed(valeur);
    });
}

Promise.reject = function (raison) {
    return new Promise((succeed, fail) => {
        fail(raison);
    });
}

/**
 * Implémentation de Deferred basée sur Promise
 * Relation entre Deferred et Promise :
 * - Deferred possède une Promise
 * - Deferred a le droit de manipuler l'état de la Promise (succeed/fail)
 *
 * Référence jQuery.Deferred
 * url: http://api.jquery.com/category/deferred-object/
 */
Promise.deferred = function() { // objet différé
    let differe = {};
    differe.promise = new Promise((succeed, fail) => {
        differe.succeed = succeed;
        differe.fail = fail;
    });
    return differe;
}

/**
 * Test de la spécification Promise/A+
 * npm i -g promises-aplus-tests
 * promises-aplus-tests Promise.js
 */

try {
  module.exports = Promise
} catch (e) {
}

  1. Tests de la Promise

npm i -g promises-aplus-tests
promises-aplus-tests Promise.js

  1. Ressources complémentaires

  • ES6-promise
  • Spécification Promise/A+ - Anglaise
  • Spécification Promise/A+ - Traduction 1
  • Spécification Promise/A+ - Traduction recommandée
  • Stack d'exécution JavaScript
  • Quatre méthodes de programmation asynchrone en JavaScript

Source : https://juejin.im/post/5aa7868b6fb9a028dd4de672

Étiquettes: Promise/A+ JavaScript programmation-asynchrone implémentation-Promise ES6

Publié le 21 septembre à 01h30