ES6 – promises

javascript

Promises (obietnice?) to nie nowa idea. Każdego dnia używam ich w swoich projektach wraz z AngularJS $q, bazującym na bibliotece: kriskowal / q.

A tool for creating and composing asynchronous promises in JavaScript.

Jest to biblioteka do obsługi i radzenia sobie z asynchronicznością w JavaScript. Zanim jednak przejdę do obietnic, napiszę co nieco o tak zwanych callback‘ach oraz ich “piekle”.


Zobacz całą serię: Let-s talk about ECMAScript 2015


callback & callback hell

Odkąd pamiętam, deweloperzy JavaScript używają callbacków (funkcji zwrotnych) do obsługi asynchroniczności oraz wszelkich zapytań HTTP. Z czasem jednak może się okazać (i pewnie nie raz sami się na to nadzialiście), że powstają nam w kodzie takie oto potwory:

console.log('start!');
setTimeout(function () {
  console.log('ping');
  setTimeout(function () {
    console.log('pong');
    setTimeout(function () {
      console.log('end!');
    }, 1000);
  }, 1000);
}, 1000);
// start!
// after 1 sec: ping
// .. 1 sec later: pong
// .. and: end!

W tym prostym przykładzie użyłem funkcji setTimeout(), by pokazać wykonywanie funkcji callback, która odpalana jest z 1-sekundowym opóźnieniem. Wygląda to koszmarnie, czytelnością zupełnie nie zachwyca, a przecież to tylko trzy wywołania funkcji. Wyobraźcie sobie jak szybko w naszym kodzie może powstać takie spaghetti. Tworzenie tego typu piramid nazwane zostało terminem: callback hell.

Możecie o nim poczytać więcej na stronie: callbackhell.com

promises

By poradzić sobie z wyżej opisanym problemem – spotykanym praktycznie wszędzie – wymyślono obietnice. Nie lubię tego zwrotu, ale oddaje on wprost sens działania.

Promise to klasa, udostępniająca API reprezentujące wartość, która będzie dostępna w momencie, gdy wywołanie zostanie skończone, a sama wartość uzupełniona (spełnienie obietnicy).

Wraz z ECMAScript2015 otrzymujemy pełne, natywne wsparcie tej techniki.

Każdy promise może być w stanie:

  • fulfilled — pomyślnie obsłużony
  • rejected — odrzucony
  • pending —  w trakcie – ani fulfilled, ani rejected
  • settled — obsłużony, fulfilled lub rejected

Zwracany promise jest obiektem, który udostępnia nam metodę then(fn: onResolve, fn: onReject), pozwalającą obsłużyć promise w momencie, gdy przechodzi on do stanu settled.

Tak, każdy promise jest obiektem, gdzie callback, to przecież funkcja. Funkcja callback pozwala nam na obsłużenie zdarzenia, a promise to obiekt, który przechowuje pewien stan.

Spójrzmy na przykład prostej obietnicy:

new Promise((resolve, reject) => {
  // when success, resolve
  let value = 'success';
  resolve(value);
 
  // when an error occurred, reject
  reject(new Error('Something happened!'));
});

Każdy promise pozwala zdefiniować funkcję (resolve), która wywołana zostanie w momencie, gdy osiągnięty zostanie stan fullfiled. W przeciwnym wypadku wykonana zostaje funkcja (reject), która obsługuje stan rejected.

Obietnice to obiekty, więc nie są one przekazywane jako parametry, jak funkcje callback – są zwracane. Najlepiej oddaje to zdanie:

The return statement is an object which is a placeholder for the result, which will be available in the future.

Obietnice biorą odpowiedzialność wyłącznie za jedno zdarzenie, gdzie funkcje callback obsługiwać mogą ich wiele, wielokrotnie.

Poprzedni przykład możemy nieco rozbudować i przypisać zwrócony obiekt promise do wyrażenia let:

let promise = new Promise((resolve, reject) => {
  // when success, resolve
  let value = 'success';
  resolve(value);
 
  // when an error occurred, reject
  reject(new Error('Something happened!'));
});

Jak już wyżej wspomniałem – zwrócony obiekt udostępnia metodę then(), która pozwala obsłużyć wartość, w momencie, gdy promise przechodzi do stanu settled:

promise.then(onResolve, onReject)

Możemy użyć tej metody do obsłużenia zwracanych wartości w ramach funkcji onResolve, onReject lub obu.

let promise = new Promise((resolve, reject) => {
  // when success, resolve
  let value = 'success';
  resolve(value);
 
  // when an error occurred, reject
  reject(new Error('Something happened!'));
});

promise.then(response => {
  console.log(response);
}, error => {
  console.log(error);
});

// success

W naszym przykładzie funkcja onReject nigdy się nie wykona i błąd nie zostanie rzucony. Możemy więc usunąć jej obsługę:

let promise = new Promise(resolve => {
  let value = 'success';
  resolve(value);
});

promise.then(response => {
  console.log(response); // success
});

W przypadku wywołania metody then(), obsługiwana jest nie tylko wartość danej obietnicy, zwracany jest również nowy obiekt Promise:

let promise = new Promise(resolve => {
  let value = 'success';
  resolve(value);
});

promise.then(response => {
  console.log(response); // success
  return 'another success';
}).then(response => {
  console.log(response); // another success 
});

Tworząc kod w ten sposób pozostaje on zawsze “płaski”. Nie budujemy tutaj piramid, więc termin callback hell może odejść w niepamięć.

W momencie, gdy interesuje nas tylko obsłużenie wyjątku (stan rejected), wystarczy, że podamy funkcję onReject do metody then():

let promise = new Promise((resolve, reject) => {
  let reason = 'failure';
  reject(reason);
});

promise.then(
  null,
  error => {
    console.log(error); // failure
  }
);

Istnieje jednak lepszy sposób. Poza metodą then(), otrzymujemy także metodę catch(), która pozwala na obsługę sytuacji wyjątkowych:

let promise = new Promise((resolve, reject) => {
  let reason = 'failure';
  reject(reason);
});

promise.catch(err => {
  console.log(err); // failure
});

W momencie, gdy mamy kilka wystąpień metody then() po sobie, wyjątek przekazywany jest tak długo, aż nie zostanie on obsłużony:

let promise = new Promise(resolve => {
  resolve();
});

promise
  .then(response => {
    return 1;
  })
  .then(response => {
    throw new Error('failure');
  })
  .catch(error => {
    console.log(error.message); // failure
  });

W momencie gdy do obsłużenia mamy wiele zdarzeń i na dodatek zależy nam, by wykonać kod po skończeniu ich wszystkich, przychodzi nam z pomocą kolejna metoda: all(). Jest to statyczna metoda klasy Promise, która jako swój argument przyjmuje tablicę obietnic do obsłużenia.

let doSmth = new Promise(resolve => {
    resolve('doSmth');
  }),
  doSmthElse = new Promise(resolve => {
    resolve('doSmthElse');
  }),
  oneMore = new Promise(resolve => {
    resolve('oneMore'); 
  });

Promise.all([
    doSmth,
    doSmthElse,
    oneMore
  ])
  .then(response => {
    let [one, two, three] = response;
    console.log(one, two, three); // doSmth doSmthElse oneMore
  });

W ramach response otrzymujemy tablicę z kolejnymi obiektami promise.

Poza tym – mamy do dyspozycji jeszcze dwie przydatne metody:

  • Promise.resolve(value)  – zwraca promise w stanie fullfiled, który przypisuje wartość value lub zwraca value jeżeli jest to już obiekt typu promise
  • Promise.reject(value) – zwraca obiekt promise w stanie rejected, gdzie wartością jest value

uwaga!

Jak wszystko, również obietnice mają swoje słabe punkty. W momencie, gdy wyjątek wystąpi w metodzie then(), musi on zostać obsłużony w naszym kodzie. W przeciwnym wypadku zostanie on zignorowany!