Promises

Opublikowane przez lewy w dniu

JavaScript to język asynchroniczny i czasem praca z nim może być uciążliwa lub mało czytelna. Z pomocą w opanowaniu tego haosu przychodzą nam właśnie Promises. Promises to nic innego jak obiekt reprezentujący wartość wykonania pewnego asynchronicznego wywołania. Jak sama nazwa wskazuje Promises to obietnica, że kiedyś (po wykonaniu wywołania) otrzymamy wynik i należy go obsłużyć. Przykładowe użycia to zapytania http czy obsługa plików (node.js).

Ok, wiedząc czym jest Promises, możemy zobaczyć jego przykład.

const promise = new Promise((resolve, rejected) => {
    const rand = Math.random();
    if (rand < 0.5) {
        resolve(rand);
    }
    rejected(rand);
});

promise
    .then((result) => console.log(`result: ${result}`))
    .catch((error) => console.error(`error: ${error}`));

W pierwszej linijce tworze nowy obiektu Promise, jak widać przyjmuje on funkcję jako argument. Funkcja ta ma 2 argumenty (callbacki) które wywołujemy w zależności czy obietnica ma być „spełniona” (poprawna wartość) czy odrzucona (błąd wykonania). W powyższym przykładzie jeżeli wylosowana liczba jest mniejsza od 0.5 to obietnica zostanie spełniona w przeciwnym wypadku zwrócony zostanie błąd. Samo utworzenie obiektu Promise jeszcze nic nie daje, dlatego wywołuję metodę then w której obsługuję poprawne zakończenie obietnicy, ewentualny błąd możemy przechwycić za pomocą metody catch.

Promise wykonuje się tylko raz! Kolejne użycie then na tym samym obiekcie zwróci ten sam wynik.

Aby uniknąć tego problemu utworzyłem sobie funkcję opakowującą Promise. Teraz używając tej funkcji tak naprawdę tworzę nowy Promise i go używam. Dodatkowo użyłem funkcji setTimeout aby uzyskać efekt opóźnienia.

function randPromise() {
    const time = Math.floor(Math.random() * 1000);
    return new Promise((resolve, rejected) => {
        setTimeout(() => {
            const rand = Math.random();
            if (rand < 0.5) {
                resolve(rand);
            }
            rejected(rand);
        }, time);
    });
}

randPromise()
    .then((result) => console.log(`1 ${result}`))
    .catch((error) => console.error(`1 error: ${error}`));

randPromise()
    .then((result) => console.log(`2 ${result}`))
    .catch((error) => console.error(`2 error: ${error}`));

Wywołując ten kod otrzymamy dwa różne wyniki z naszego Promise.

Obsługa wyniku wywołania Promise

Często występuje potrzeba wykonania pewnej czynności dopiero po otrzymaniu wyniku z obietnicy. Wówczas kod może wyglądać w ten sposób.

randPromise()
    .then((result) => {
        console.log(`randPromise: ${result}`);
        doSth(result);
    })
    .catch((error) => {
        console.error(`error randPromise: ${error}`)
    });

Nie wygląda to jeszcze źle, ale co w sytuacji gdy wywołanie kolejnej funkcji to znowu Promise. W przypadku paru takich wywołań trafiamy na „Promise hell”.

Promise hell

randPromise()
    .then((result) => {
        console.log(`randPromise: ${result}`);
        randPromise()
            .then((result2) => {
                console.log(`randPromise: ${result2}`);
                randPromise()
                    .then((result3) => {
                        console.log(`randPromise: ${result3}`);
                        doSth(result, result2, result3);
                    });
            });
    })
    .catch((error) => {
        console.error(`error randPromise: ${error}`)
    });

Kolejny problem który powstaje w powyższym kodzie to brak obsługi błędów dla „zagnieżdżonych” obietnic. Do każdego then powinien dojść jeszcze osobny catch a to obniżyłoby czytelność powyższego kodu jeszcze bardziej.

Łańcuch wywołań Promises (chained Promises)

Na szczęście można ten kod uprościć i nie jest to bardzo trudna rzecz. Wystarczy że jako argument metody then podamy funkcję zwracającą kolejny Promise do wywołania.

function logResultAndRandPromise(result) {
    console.log(`randPromise: ${result}`);
    return randPromise();
}

randPromise()
    .then((result) => logResultAndRandPromise(result))
    .then((result2) => logResultAndRandPromise(result2))
    .then((result3) => {
        console.log(`randPromise: ${result3}`);
        doSth(result, result2, result3);
    })
    .catch((error) => {
        console.error(`error randPromise: ${error}`);
    });

Powyższy kod jest czytelniejszy, a dodatkowo za pomocą ostatniego catch obsługuję błąd w każdym z Promise.

Istotna jest tutaj informacja o tym, że catch wykona się jeżeli któryś z Promise zakończy się błędem i dalsze (jeżeli występowały) Promise nie zostaną już wywołane.

Kod możemy jeszcze bardziej uprościć dając samą nazwę funkcji która ma obsłużyć rezultat wywołania.

function saveResultAndRandPromise(result) {
    result = Array.isArray(result) ? result : [result];
    return randPromise()
        .then((result2) => [...result, result2]);
}

randPromise()
    .then(saveResultAndRandPromise)
    .then(saveResultAndRandPromise)
    .then((result3) => {
        log(`randPromise: ${result3.join(' ; ')}`);
    })
    .catch((error) => {
        logError(`error randPromise: ${error}`)
    });

Wówczas jednak należy zadbać o odpowiednie obsłużenie rezultatu wywołania, ponieważ funkcja taka przyjmie tylko jeden argument. W powyższym przykładzie wynik jednego wywołania wstawiam do tablicy wyników która jest zwracana przez moją funkcję do obsługi wywołań. Każde kolejne wywołanie Promise powiększa moją tablicę rezultatów o wynik kolejnego zapytania. W ostatnim wywołaniu rezultat jest tablicą wszystkich poprzednich wywołań i mam dostęp do wyników wszystkich Promise.

Pułapki wywołań Promises

Na koniec przestroga przed błędnym wywoływaniem kolejnych Promise w łańcuchu.

let counter = 0;
function randPromise() {
    const time = Math.floor(Math.random() * 1000);
    counter++;
    return new Promise((resolve, rejected) => {
        setTimeout(() => {
            resolve({counter, time});
        }, time);
    });
}

function wrappedRandPromise(index, sthToLog) {
    if (sthToLog) {
        console.log(index, sthToLog);
    }
    return randPromise();
}

wrappedRandPromise(1)
    .then((result) => {
        wrappedRandPromise(2, result);
    })
    .then((result) => wrappedRandPromise(3, result)
        .then((result) => console.log(result)))
    .then((result) => wrappedRandPromise(4, result))
    .then((result) => {
        wrappedRandPromise(5, result);
    })
    .then(wrappedRandPromise(6))
    .catch((error) => {
        logError(`error randPromise: ${error}`)
    });

Wynik działania powyższego kodu może być zaskoczeniem (albo nie).

$ node promise8.js 
2 { counter: 2, time: 983 }
{ counter: 4, time: 705 }
5 { counter: 5, time: 640 }

Dlaczego wynik jest taki a nie inny, już tłumaczę.

Pierwsze wywołanie jest oczywiste i jego wynik przekazuje do kolejnego wywołania wrappedRandPromise co powoduje wyświetlenie na konsoli 2 { counter: 2, time: 983 }. Wywołanie funkcji natomiast nigdzie nie jest obsłużone. Nie ma po nim ani osobnego then ani wynik wywołania nie jest przekazany dalej w łańcuchu wywołań ponieważ nie użyłem słowa kluczowego return.
W takim przypadku domyślnie funkcja zwróci undefined i taka też jest wartość result w lini 23. Skoro kolejne wywołanie wrappedRandPromise(3, result) jako result przyjmuje undefined w związku z tym funkcja nic nie wyświetla na konsolę. Następnie wywołanie to ma swojego osobnego then który je obsługuje w związku z tym na konsoli pojawia się wpis { counter: 4, time: 705 }.
Jako że console.log nie zwraca żadnej wartości, to zwrócony ponownie zostaje undefined i taką wartość ma result w lini 25. Wywołanie kolejnego wrappedRandPromise(4, result) nie wypisze nam nic na konsolę. Natomiast zastosowałem tu skrócony zapis arrow function i wartość z tego wywołania zwracana jest dalej.
result z linia 26 przyjmuje wartość z poprzedniego wywołania funkcji wrappedRandPromise i przekazuje ją do wrappedRandPromise(5, result). Powoduje to wyświetlenie na konsoli 5 { counter: 5, time: 640 }.
Wywołanie z lini 29 (then(wrappedRandPromise(6))) powoduje natychmiastowe wykonanie funkcji, odpalenie Promise a dopiero wynik tego wywołania jest traktowany jako callback dla poprzedniego Promise z łańcucha.

Aby wynik był bardziej zbliżony do zamierzonego to powinienem w liniach 21 i 27 dodać słówko return oraz w lini 24 console.log opakować funkcją w której wyświetlę wynik oraz zwrócę go dalej.

Przypadkiem w którym powyższy zapis ma sens jest sytuacja w której chciałbym po otrzymaniu wyniku z Promise wykonać kolejny Promise który byłby „side effectem” a jego wynik nie wypływałby na łańcuch. Wówczas brak słówka return spowoduje przyspieszenie wykonywania łańcucha ponieważ nie oczekiwałby on na zakończenie poprzedniego wywołania.

Bonusy

Kody które wykorzystałem w tym poście można znaleźć na githubie.

I na koniec nowość, postanowiłem zacząć nagrywać filmiki z kodem opisywanym na blogu, pierwszy dotyczący Promises znajduje się tu: https://youtu.be/rTaYLYaZAlc

Drugi wpis poświęcony Promise znajdziesz tutaj.

Kategorie: JavaScript

0 Komentarzy

Dodaj komentarz

Avatar placeholder

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *