Promises

Obietnice, słodkie obietnice. Przed każdymi wyborami słyszymy ich wiele, a potem jak jest - każdy wie. Javascript udostępnia nam klasę Promise na bazie której możemy tworzyć obiekty, które ułatwiają nam działania z operacjami asynchronicznymi.

Wyobraź sobie, że masz funkcję asynchroniczną. Dla uproszczenia przykładu będziemy ją symulować za pomocą setTimeout, ale ogólnie rozchodzi się o wszelkie wczytywania danych czy operacje, które zajmują jakiś czas.


setTimeout(() => {
    console.log("Wczytaliśmy dane 1");
}, 2000)

Powiedzmy teraz, że do działania naszego widoku (np. edycji użytkownika) powinniśmy wczytać 3 różne partie danych:


setTimeout(() => {
    console.log("Wczytaliśmy dane 1");

    setTimeout(() => {
        console.log("Wczytaliśmy dane 2");

        setTimeout(() => {
            console.log("Wczytaliśmy dane 1");

            showView();
        }, 2000);

    }, 2000);

}, 2000)

W bardziej realnej sytuacji mogło by to wyglądać tak jak poniżej:


const verifyUser = function(username, password, callback){
   dataBase.verifyUser(username, password, (error, userInfo) => {
       if (error) {
           callback(error)
       } else {
           dataBase.getRoles(username, (error, roles) => {
               if (error) {
                   callback(error)
               } else {
                   dataBase.logAccess(username, (error) => {
                       if (error) {
                           callback(error);
                       } else {
                           callback(null, userInfo, roles);
                       }
                   })
               }
           })
       }
   })
};

Jak widzisz, w obydwu przypadkach robi nam się z kodu choinka (zwana potocznie callback hell), która nie tylko staje się ciężka do późniejszego opanowania, ale i trudna do testów, bo musimy testować wszystko na raz.
Czy nie było by lepiej, gdybyśmy mogli zrobić 3 oddzielne funkcje, które moglibyśmy następnie odpalać jedna po drugiej?


function loadData1() { ... }

function loadData2() { ... }

function loadData3() { ... }

function showView() { ... }

loadData1()
.then(loadData2())
.then(loadData3())
.then(showView());

I tutaj właśnie przychodzą nam z pomocą obietnice, które jak sama nazwa wskazuje służą właśnie do... składania obietnic. Idealne miejsce dla takich obietnic to kod asynchroniczny, w którym bardzo często pojawia się sytuacja typu "jeżeli dany kod się wykona, zacznij wykonywać inny".

Ogólna konstrukcja wywołania Promise ma postać:


const promise = new Promise((resolve, reject) => {
    resolve("Wszystko ok");
    reject("Nie jest ok");
});

Jak widzisz w parametrze Promise przekazujemy funkcję, która ma 2 opcjonalne parametry. Są nimi funkcje, które będą wywoływane w momencie zakończenia działania Promisa.
Każda obietnica może zakończyć się na dwa sposoby - powodzeniem i niepowodzeniem.
Gdy obietnica zakończy się powodzeniem (np. dane się wczytają), powinniśmy wywołać funkcję resolve(), do której przekażemy poprawny rezultat. W przypadku błędów powinniśmy wywołać funkcję reject(), do której trafią błędne dane.

Promise

Po stworzeniu nowej instancji Promise, w pierwszym momencie zwracany jest obiekt, który początkowo ma właściwości state ustawioną na "pending" oraz właściwość result, która początkowo wynosi undefined.

Promise pending

W momencie zakończenia wykonywania Promise dana obietnica przechodzi w stan "settled" (ustalony/załatwiony) i zostaje zwrócony jakiś wynik. Status takiego promise przełączany jest odpowiednio w "fulfilled" lub "rejected", a do odpowiedniej funkcji (resolve/reject)przekazywany jest poprawny lub błędny wynik.

Promise resolve

Od tego momentu możemy skorzystać z dodatkowych metod (np. then()), które służą do reakcji na zakończenie danego Promise. Będziemy omawiać je poniżej.


const loadData = new Promise((resolve, reject) => {
    setTimeout(() => { //w ramach testu symulujemy opóźnienie wczytywania
        if (result.ok) {
            resolve(result.data);
        } else {
            reject(new Error("Błędne dane"));
        }
    }, 2000);
});


loadData.then(
    result => console.log(result),
    error => console.error(error);,
)

Powyższe listingi to oczywiście tylko rozważanie.
Bardziej praktyczny przykład - czyli pobieranie danych z serwera za pomocą XMLHTTPRequest może mieć postać:


const loadData = new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", 'http://example-serwer');
    xhr.addEventListener('load', () => resolve(xhr.responseText));
    xhr.addEventListener('error', () => reject(new Error(xhr.statusText)));
    xhr.send();
});

loadData.then(
    result => console.log(result),
    error => console.error(error);,
)

Promise.then()

Po rozwiązaniu Promise możemy zareagować na jego wynik. Służą do tego dodatkowe metody. Pierwszą z tych metod jest then(). Pozwala ona reagować zarówno na pozytywne rozwiązanie obietnicy, negatywne jak i oba na raz:


const promise = new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", 'http://......');
    xhr.addEventListener('load', () => resolve(xhr.responseText));
    xhr.send();
});

promise.then(result => {
    //Promise zakończyło się pozytywnie
});

const promise = new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", 'http://......');
    xhr.addEventListener('load', () => resolve(xhr.responseText));
    xhr.addEventListener('error', () => reject(new Error(xhr.statusText));
    xhr.send();
});

promise.then(
    null, //nie interesuje nas pozytywne rozwiązanie
    error => console.error(error)
);

const promise = new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", 'http://......');
    xhr.addEventListener('load', () => resolve(xhr.responseText));
    xhr.addEventListener('error', () => reject(new Error(xhr.statusText)));
    xhr.send();
});

promise.then(
    result => console.log(result),
    error => console.error(error)
);

Promise.catch()

Promise może zwracać odpowiedź pozytywną (resolve) lub negatywną (reject). Do reakcji na negatywną odpowiedź możemy albo użyć tak jak powyżej drugiego parametru funkcji then(), albo metody catch():.


const promise = new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", 'http://......');
    xhr.addEventListener('load', () => resolve(xhr.responseText));
    xhr.addEventListener('error', () => reject(xhr.statusText));
    xhr.send();
});

promise.then(
    null,
    error => console.error(error)
);

promise
.then(result => { ... })
.catch(error => console.error(error));

Użycie w funkcji

Jeżeli teraz taki obiekt Promise zostanie zwrócony przez jakąś funkcję, możemy na takiej funkcji działać tak samo jak powyżej działaliśmy na Promise:


function loadData() {
    return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open("GET", 'http://......');
        xhr.addEventListener('load', () => resolve(xhr.responseText));
        xhr.addEventListener('error', () => reject(xhr.statusText));
        xhr.send();
    });
}

loadData()
.then(result => console.log(result))
.catch(err => console.error(err))

function loadImage(url) {
    return new Promise((resolve, reject) => {
        let img = new Image();
        img.addEventListener('load', () => resolve(img));
        img.addEventListener('error', () => {
            reject(new Error(`Failed to load image's URL: ${url}`));
        });
        img.src = url;
    });
}

loadImage('pies.jpg')
.then(result => console.log(result))
.catch(err => console.error(err))

Przykładowe zastosowanie może mieć postać:


function insertUsers(users) {
    users.forEach(user => {
        console.log(user);
    })
}

function readData() {
    return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open('GET', 'https://jsonplaceholder.typicode.com/posts');
        xhr.onload = function() {
            if (this.status === 200) {
                resolve(JSON.parse(xhr.responseText));
            } else {
                reject('Wystąpił błąd w ściąganiu danych');
            }
        }
        xhr.addEventListener('error', () => reject(xhr.statusText));
        xhr.send();
    })
}


readData()
.then(data => insertUsers(data))
.catch(err => console.error(err))

Jak widzisz już nie musimy wywoływać funkcji z wnętrza asynchronicznych obiektów. W tej chwili nasze funkcje stały się autonomicznymi klockami, które możemy używać jak klasyczne funkcje. Dzięki temu nasz kod staje się o wiele czytelniejszy.

Promise.all()

Podobnych funkcji asynchronicznych jak powyższa readData() może być więcej - np. w przypadku gdy chcemy pobrać dane z kilku źródeł. Bardzo często resztę kodu chcielibyśmy wykonać dopiero po zakończeniu działania ich wszystkich. Aby poczekać na zakończenie wszystkich wskazanych promisów użyjemy metody Promise.all() do której przekażemy tablicę zawierającą nasze promisy:


function checkData1() {
    return new Promise((resolve, reject) => {
        setTimeout(function() {
            resolve('OK1');
        }, 1000)
    });
}

function checkData2() {
    return new Promise((resolve, reject) => {
        setTimeout(function() {
            resolve('OK2');
        }, 2000)
    });
}


Promise.all([
    checkData1(),
    checkData2()
])
.then(resp => {
    console.log(resp); //["OK1", "OK2"]
    console.log(resp[0]); //OK1
    console.log(resp[1]); //OK2
});

Promise.race()

Jeżeli powyższa metoda Promise.all() czekała na zakończenie wszystkich obietnic, tak metoda race() zwróci pierwszy zakończony promise:


function checkData1() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('OK1');
        }, 2000)
    });
}

function checkData2() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('OK2');
        }, 1000)
    });
}

Promise.race([
    checkData1(),
    checkData2()
])
.then(resp => {
    console.log(resp); //OK2
});

Promise.finally()

Jeżeli interesowałeś się rozdziałem związanym z AJAX w jQuery, pewnie pamiętasz funkcja .done(), .fail() i .always().

I tak funkcja done() odpalana jest po pozytywnym zakończeniu połączenia, metoda fail() odpalana jest w momencie błędu, a metoda always() odpalana jest po zakończeniu połączenia - nieważne czy zakończyło się pozytywnie czy negatywnie.

Porównując to do powyższych kodów, done() to taki nasz then(), fail() to catch(), ale gdzie podział się always()?

W wersji Ecmascript 2018 wprowadzono nową funkcję finally(), która jest odpowiednikiem właśnie funkcji always() w jQuerowym Ajax, co oznacza że odpalana jest po zakończeniu Promise, bez względu czy zakończyło się powodzeniem czy nie.


btn.classList.add("loading"); //do buttony dodajemy loading
btn.disabled = true; //i go wyłączamy

fetch('....')
.then(res => res.json())
.then(res => console.log(res))
.catch(err => alert(err))
.finally(() => {
    btn.classList.remove("loading");
    btn.disabled = false;
});

Łańcuchowe odpowiedzi

Jeżeli dana funkcja zwróci nowy promise, możemy na niej wykonać jedną z powyższych metod czyli then, catch itp.

Rozważmy powyższy przykład. Każda z funkcji zwraca nowy Promise, więc możemy na niej wykonać then. Jeżeli w takim then znowu zwrócimy Promise, możemy znowu wykonać kolejne then.


function checkData1() {
    return new Promise((resolve, reject) => {
        setTimeout(() => resolve('OK1'), 2000);
    });
}

function checkData2() {
    return new Promise((resolve, reject) => {
        setTimeout(() => resolve('OK2'), 2000);
    });
}

function checkData3() {
    return new Promise((resolve, reject) => {
        setTimeout(() => resolve('OK3'), 2000);
    });
}

checkData1()
.then(res => checkData2())
.then(res => checkData3())
.then(resp => {
    console.log(resp); //OK3
});

Co ciekawe samo then() zwraca nam promise, więc możemy je łączyć w łańcuchy bez potrzeby tworzenia specjalnych funkcji jak powyżej:


new Promise((resolve, reject) => setTimeout(() => {
    resolve(10);
}, 2000))
.then(num => {
    console.log('first then: ', num);
    return num * 2;
})
.then(num => {
    console.log('second then: ', num);
    return num * 2;
})
.then(num => {
    console.log('last then: ', num);
});

Jeżeli któreś z powyższych funkcji then() zwróci normalną wartość (string, numer, boolean itp), zostanie ta wartość potraktowana jako resolve. Jeżeli jednak któreś z tych then() wyrzuci taką wartość za pomocą throw, wtedy zostanie to potraktowane jako reject:


new Promise((resolve, reject) => setTimeout(() => {
    resolve(10);
}, 2000))
.then(num => {
    console.log('first then: ', num);
    return num * 2;
})
.then(num => {
    console.log('second then: ', num);
    throw num * 2;
})
.then(num => {
    //to się nie wykona, bo powyżej został zwrócony reject
    console.log('last then: ', num);
})
.catch(error => {
    //to się wykona bo reagujemy na reject
    console.warn('Błąd', error);
})

Powyższe łączenie then będziesz stosował przy np. ściąganiu danych za pomocą fetch, które też zwraca promise:


fetch('....')
    .then(res => res.json())
    .then(res => {
        console.log(res);
    })