Obiekty Promise w języku JavaScript: wprowadzenie

Obiekty Promise upraszczają obliczenia odroczone i asynchroniczne. Obietnica reprezentuje operację, która nie została jeszcze zakończona.

Jake Archibald
Jake Archibald

Deweloperzy, przygotujcie się na przełomowy moment w historii tworzenia stron internetowych.

[Zaczyna się werbel]

Promises have arrived in JavaScript!

[Fireworks explode, glittery paper rains from above, the crowd goes wild]

W tym momencie należysz do jednej z tych kategorii:

  • Ludzie wokół Ciebie wiwatują, ale nie wiesz, o co chodzi. Może nawet nie wiesz, czym jest „obietnica”. Wzruszasz ramionami, ale czujesz na nich ciężar błyszczącego papieru. Jeśli tak, nie martw się. Mi też zajęło sporo czasu, zanim zrozumiałem, dlaczego warto się tym przejmować. Najlepiej zacząć od początku.
  • Uderzasz pięścią w powietrze. Najwyższy czas, prawda? Znasz już obiekty Promise, ale przeszkadza Ci, że wszystkie implementacje mają nieco inny interfejs API. Jak wygląda interfejs API oficjalnej wersji JavaScriptu? Zacznij od terminologii.
  • Wiedziałeś(-aś) o tym już wcześniej i śmiejesz się z tych, którzy skaczą z radości, jakby to była dla nich nowość. Poświęć chwilę na docenienie własnych umiejętności, a następnie przejdź do dokumentacji API.

Obsługa przeglądarek i polyfill

Browser Support

  • Chrome: 32.
  • Edge: 12.
  • Firefox: 29.
  • Safari: 8.

Source

Aby dostosować przeglądarki, które nie mają pełnej implementacji obietnic, do specyfikacji lub dodać obietnice do innych przeglądarek i Node.js, zapoznaj się z polyfill (2 kB po skompresowaniu).

O co tyle hałasu?

JavaScript jest jednowątkowy, co oznacza, że 2 fragmenty skryptu nie mogą działać jednocześnie – muszą być wykonywane jeden po drugim. W przeglądarkach JavaScript współdzieli wątek z wieloma innymi elementami, które różnią się w zależności od przeglądarki. Zwykle jednak JavaScript znajduje się w tej samej kolejce co renderowanie, aktualizowanie stylów i obsługa działań użytkownika (takich jak zaznaczanie tekstu i interakcja z elementami sterującymi formularza). Aktywność w jednym z tych obszarów opóźnia pozostałe.

Jako człowiek jesteś wielowątkowy. Możesz pisać wieloma palcami, możesz prowadzić samochód i rozmawiać jednocześnie. Jedyną funkcją blokującą, z którą musimy się zmierzyć, jest kichanie, podczas którego cała bieżąca aktywność musi zostać zawieszona. To dość irytujące, zwłaszcza gdy prowadzisz samochód i próbujesz rozmawiać. Nie chcesz pisać kodu, który jest „kichający”.

Prawdopodobnie używasz zdarzeń i wywołań zwrotnych, aby to obejść. Oto wydarzenia:

var img1 = document.querySelector('.img-1');

img1.addEventListener('load', function() {
  // woo yey image loaded
});

img1.addEventListener('error', function() {
  // argh everything's broken
});

To wcale nie jest kichanie. Pobieramy obraz, dodajemy kilka detektorów, a następnie JavaScript może przestać działać, dopóki nie zostanie wywołany jeden z tych detektorów.

Niestety w powyższym przykładzie zdarzenia mogły wystąpić, zanim zaczęliśmy je nasłuchiwać, więc musimy to obejść, używając właściwości „complete” obrazów:

var img1 = document.querySelector('.img-1');

function loaded() {
  // woo yey image loaded
}

if (img1.complete) {
  loaded();
}
else {
  img1.addEventListener('load', loaded);
}

img1.addEventListener('error', function() {
  // argh everything's broken
});

This doesn't catch images that errored before we got a chance to listen for them; unfortunately the DOM doesn't give us a way to do that. Poza tym wczytuje się tylko 1 obraz. Sprawa komplikuje się jeszcze bardziej, gdy chcemy wiedzieć, kiedy został wczytany zestaw obrazów.

Zdarzenia nie zawsze są najlepszym rozwiązaniem

Zdarzenia świetnie sprawdzają się w przypadku rzeczy, które mogą wystąpić wielokrotnie na tym samym obiekcie, np. keyup, touchstart itd. W przypadku tych zdarzeń nie ma znaczenia, co działo się przed dołączeniem detektora. Jeśli jednak chodzi o powodzenie lub niepowodzenie operacji asynchronicznej, najlepiej byłoby mieć coś takiego:

img1.callThisIfLoadedOrWhenLoaded(function() {
  // loaded
}).orIfFailedCallThis(function() {
  // failed
});

// and…
whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
  // all loaded
}).orIfSomeFailedCallThis(function() {
  // one or more failed
});

To samo robią obiekty Promise, ale mają lepsze nazwy. Gdyby elementy graficzne HTML miały metodę „ready”, która zwraca obietnicę, moglibyśmy zrobić tak:

img1.ready()
.then(function() {
  // loaded
}, function() {
  // failed
});

// and…
Promise.all([img1.ready(), img2.ready()])
.then(function() {
  // all loaded
}, function() {
  // one or more failed
});

W najprostszym ujęciu obietnice są podobne do detektorów zdarzeń, ale:

  • Obietnica może zostać spełniona lub nie tylko raz. Nie może zakończyć się powodzeniem ani niepowodzeniem dwukrotnie, nie może też zmienić stanu z powodzenia na niepowodzenie ani odwrotnie.
  • Jeśli obietnica zakończyła się powodzeniem lub niepowodzeniem, a później dodasz wywołanie zwrotne dotyczące powodzenia lub niepowodzenia, zostanie wywołane prawidłowe wywołanie zwrotne, mimo że zdarzenie miało miejsce wcześniej.

Jest to bardzo przydatne w przypadku asynchronicznego powodzenia lub niepowodzenia, ponieważ nie interesuje Cię dokładny czas, kiedy coś stało się dostępne, ale raczej reakcja na wynik.

Terminologia dotycząca obietnic

Domenic Denicola przeczytał pierwszą wersję roboczą tego artykułu i ocenił moją terminologię na „niedostateczną”. Zatrzymał mnie po lekcjach, zmusił do przepisania 100 razy States and Fates i napisał list do moich rodziców. Mimo to nadal mylę wiele terminów, ale oto podstawy:

Obietnica może być:

  • fulfilled – działanie związane z obietnicą zostało wykonane,
  • rejected – działanie związane z obietnicą nie powiodło się.
  • pending – prośba nie została jeszcze zrealizowana ani odrzucona.
  • settled – zrealizowano lub odrzucono,

Specyfikacja używa też terminu thenable do opisania obiektu, który jest podobny do obietnicy, ponieważ ma metodę then. To określenie przypomina mi byłego trenera reprezentacji Anglii w piłce nożnej, Terry'ego Venablesa, więc będę go używać jak najrzadziej.

Obiekty Promise w JavaScripcie

Obietnice są już od jakiegoś czasu dostępne w formie bibliotek, takich jak:

Powyższe obiekty i obiekty Promise w JavaScript mają wspólne, ustandaryzowane zachowanie o nazwie Promises/A+. Jeśli używasz jQuery, znajdziesz tam coś podobnego o nazwie Deferreds. Jednak obiekty Deferred nie są zgodne ze specyfikacją Promise/A+, co sprawia, że nieznacznie się różnią i są mniej przydatne, więc należy zachować ostrożność. jQuery ma też typ Promise, ale jest to tylko podzbiór Deferred i ma te same problemy.

Chociaż implementacje obietnic są zgodne ze standardowym zachowaniem, ich ogólne interfejsy API się różnią. Obiekty Promise w języku JavaScript mają interfejs API podobny do RSVP.js. Aby utworzyć obietnicę:

var promise = new Promise(function(resolve, reject) {
  // do a thing, possibly async, then…

  if (/* everything turned out fine */) {
    resolve("Stuff worked!");
  }
  else {
    reject(Error("It broke"));
  }
});

Konstruktor obietnicy przyjmuje 1 argument, czyli wywołanie zwrotne z 2 parametrami: resolve i reject. Wykonaj jakąś czynność w ramach wywołania zwrotnego, być może asynchronicznie, a następnie wywołaj funkcję resolve, jeśli wszystko zadziałało, lub reject, jeśli nie.

Podobnie jak w przypadku throw w zwykłym JavaScripcie, zwyczajowo (ale nie jest to wymagane) odrzuca się obiekt błędu. Zaletą obiektów błędu jest to, że przechwytują zrzut stosu, dzięki czemu narzędzia do debugowania są bardziej pomocne.

Oto jak możesz wykorzystać tę obietnicę:

promise.then(function(result) {
  console.log(result); // "Stuff worked!"
}, function(err) {
  console.log(err); // Error: "It broke"
});

then() przyjmuje 2 argumenty: wywołanie zwrotne w przypadku powodzenia i inne w przypadku niepowodzenia. Oba są opcjonalne, więc możesz dodać wywołanie zwrotne tylko w przypadku powodzenia lub niepowodzenia.

Obiekty Promise w języku JavaScript zaczęły się w DOM jako „Futures”, zostały przemianowane na „Promises”, a na koniec przeniesione do JavaScriptu. Umieszczenie ich w JavaScripcie, a nie w DOM, jest świetnym rozwiązaniem, ponieważ będą one dostępne w kontekstach JS innych niż przeglądarka, takich jak Node.js (to, czy będą z nich korzystać w swoich podstawowych interfejsach API, to inna kwestia).

Chociaż są funkcją JavaScriptu, DOM nie boi się ich używać. Wszystkie nowe interfejsy API DOM z asynchronicznymi metodami powodzenia/niepowodzenia będą używać obietnic. Dzieje się tak już w przypadku zarządzania limitami, zdarzeń wczytywania czcionek, ServiceWorker, Web MIDI, strumieni i innych funkcji.

Zgodność z innymi bibliotekami

Interfejs API obietnic JavaScriptu będzie traktować wszystko, co ma metodę then(), jako obietnicę (lub thenable w języku obietnic sigh), więc jeśli używasz biblioteki, która zwraca obietnicę Q, to nie ma problemu, będzie ona dobrze współpracować z nowymi obietnicami JavaScriptu.

Jednak, jak wspomniałem, Deferreds w jQuery są nieco… nieprzydatne. Na szczęście można je przekształcić w standardowe obiekty Promise, co warto zrobić jak najszybciej:

var jsPromise = Promise.resolve($.ajax('/whatever.json'))

W tym przypadku metoda $.ajax jQuery zwraca obiekt Deferred. Ponieważ zawiera metodę then(), Promise.resolve() może przekształcić ją w obietnicę JavaScriptu. Czasami jednak obiekty deferred przekazują do wywołań zwrotnych wiele argumentów, na przykład:

var jqDeferred = $.ajax('/whatever.json');

jqDeferred.then(function(response, statusText, xhrObj) {
  // ...
}, function(xhrObj, textStatus, err) {
  // ...
})

Natomiast obietnice JS ignorują wszystkie oprócz pierwszej:

jsPromise.then(function(response) {
  // ...
}, function(xhrObj) {
  // ...
})

Na szczęście zwykle jest to właśnie to, czego potrzebujesz, lub przynajmniej daje Ci dostęp do tego, czego potrzebujesz. Pamiętaj też, że jQuery nie stosuje konwencji przekazywania obiektów Error do odrzuceń.

Uproszczenie złożonego kodu asynchronicznego

Dobrze, napiszmy trochę kodu. Załóżmy, że chcemy:

  1. Uruchom wskaźnik postępu, aby pokazać, że trwa wczytywanie.
  2. pobieranie pliku JSON z tytułem i adresami URL poszczególnych rozdziałów;
  3. Dodaj tytuł do strony
  4. Pobierz każdy rozdział
  5. Dodawanie artykułu do strony
  6. Zatrzymaj spinner

…ale też poinformować użytkownika, jeśli coś poszło nie tak. W tym momencie chcemy też zatrzymać wskaźnik, bo inaczej będzie się kręcił, aż dostanie zawrotów głowy i zderzy się z innym elementem interfejsu.

Oczywiście nie użyjesz JavaScriptu do dostarczenia artykułu, ponieważ wyświetlanie w formacie HTML jest szybsze, ale ten wzorzec jest dość powszechny w przypadku interfejsów API: pobieranie wielu danych, a następnie wykonanie działania, gdy wszystko jest gotowe.

Zacznijmy od pobierania danych z sieci:

Promisifying XMLHttpRequest

Stare interfejsy API zostaną zaktualizowane, aby używać obietnic, jeśli jest to możliwe w sposób zgodny wstecznie. XMLHttpRequest jest doskonałym kandydatem, ale tymczasem napiszmy prostą funkcję, która wysyła żądanie GET:

function get(url) {
  // Return a new promise.
  return new Promise(function(resolve, reject) {
    // Do the usual XHR stuff
    var req = new XMLHttpRequest();
    req.open('GET', url);

    req.onload = function() {
      // This is called even on 404 etc
      // so check the status
      if (req.status == 200) {
        // Resolve the promise with the response text
        resolve(req.response);
      }
      else {
        // Otherwise reject with the status text
        // which will hopefully be a meaningful error
        reject(Error(req.statusText));
      }
    };

    // Handle network errors
    req.onerror = function() {
      reject(Error("Network Error"));
    };

    // Make the request
    req.send();
  });
}

Teraz użyjmy go:

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.error("Failed!", error);
})

Teraz możemy wysyłać żądania HTTP bez ręcznego wpisywania XMLHttpRequest, co jest świetne, ponieważ im mniej muszę widzieć irytującą pisownię camelCase XMLHttpRequest, tym szczęśliwsze będzie moje życie.

Łączenie

then() to nie koniec historii. Możesz łączyć then, aby przekształcać wartości lub wykonywać dodatkowe działania asynchroniczne jedno po drugim.

Przekształcanie wartości

Wartości możesz przekształcać, zwracając nową wartość:

var promise = new Promise(function(resolve, reject) {
  resolve(1);
});

promise.then(function(val) {
  console.log(val); // 1
  return val + 2;
}).then(function(val) {
  console.log(val); // 3
})

Wróćmy do praktycznego przykładu:

get('story.json').then(function(response) {
  console.log("Success!", response);
})

Odpowiedź jest w formacie JSON, ale obecnie otrzymujemy ją jako zwykły tekst. Możemy zmienić funkcję get, aby używała JSON responseType, ale możemy też rozwiązać ten problem za pomocą obietnic:

get('story.json').then(function(response) {
  return JSON.parse(response);
}).then(function(response) {
  console.log("Yey JSON!", response);
})

Ponieważ funkcja JSON.parse() przyjmuje jeden argument i zwraca przekształconą wartość, możemy użyć skrótu:

get('story.json').then(JSON.parse).then(function(response) {
  console.log("Yey JSON!", response);
})

W rzeczywistości możemy bardzo łatwo utworzyć funkcję getJSON():

function getJSON(url) {
  return get(url).then(JSON.parse);
}

getJSON() nadal zwraca obietnicę, która pobiera adres URL, a następnie analizuje odpowiedź jako JSON.

Kolejkowanie działań asynchronicznych

Możesz też połączyć then, aby uruchamiać działania asynchroniczne po kolei.

Gdy zwracasz coś z wywołania zwrotnego then(), dzieje się coś magicznego. Jeśli zwrócisz wartość, następna funkcja then() zostanie wywołana z tą wartością. Jeśli jednak zwrócisz coś podobnego do obietnicy, następna funkcja then() będzie na nią czekać i zostanie wywołana dopiero po rozstrzygnięciu obietnicy (powodzeniu lub niepowodzeniu). Na przykład:

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  console.log("Got chapter 1!", chapter1);
})

Wysyłamy asynchroniczne żądanie do story.json, które zwraca zestaw adresów URL do żądania, a następnie wysyłamy żądanie do pierwszego z nich. Wtedy obietnice zaczynają się naprawdę wyróżniać na tle prostych wzorców wywołań zwrotnych.

Możesz nawet utworzyć metodę skrótu, aby pobierać rozdziały:

var storyPromise;

function getChapter(i) {
  storyPromise = storyPromise || getJSON('story.json');

  return storyPromise.then(function(story) {
    return getJSON(story.chapterUrls[i]);
  })
}

// and using it is simple:
getChapter(0).then(function(chapter) {
  console.log(chapter);
  return getChapter(1);
}).then(function(chapter) {
  console.log(chapter);
})

Nie pobieramy story.json, dopóki nie zostanie wywołana funkcja getChapter, ale przy kolejnych wywołaniach funkcji getChapter ponownie używamy obietnicy dotyczącej historii, więc story.json jest pobierany tylko raz. Yay Promises!

Obsługa błędów

Jak widzieliśmy wcześniej, then() przyjmuje 2 argumenty: jeden dla powodzenia, a drugi dla niepowodzenia (lub spełnienia i odrzucenia, w języku obietnic):

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.log("Failed!", error);
})

Możesz też użyć catch():

get('story.json').then(function(response) {
  console.log("Success!", response);
}).catch(function(error) {
  console.log("Failed!", error);
})

W catch() nie ma nic szczególnego, to tylko uproszczona wersja then(undefined, func), ale jest bardziej czytelna. Pamiętaj, że te 2 przykłady kodu nie działają tak samo. Drugi z nich jest odpowiednikiem:

get('story.json').then(function(response) {
  console.log("Success!", response);
}).then(undefined, function(error) {
  console.log("Failed!", error);
})

Różnica jest niewielka, ale bardzo przydatna. Odrzucenia obietnic przechodzą do następnego elementu then() z wywołaniem zwrotnym odrzucenia (lub catch(), ponieważ jest to równoważne). W przypadku then(func1, func2) wywoływana jest funkcja func1 lub func2, ale nigdy obie. Jednak w przypadku then(func1).catch(func2) obie funkcje zostaną wywołane, jeśli func1 odrzuci obietnicę, ponieważ są to osobne kroki w łańcuchu. Wykonaj te czynności:

asyncThing1().then(function() {
  return asyncThing2();
}).then(function() {
  return asyncThing3();
}).catch(function(err) {
  return asyncRecovery1();
}).then(function() {
  return asyncThing4();
}, function(err) {
  return asyncRecovery2();
}).catch(function(err) {
  console.log("Don't worry about it");
}).then(function() {
  console.log("All done!");
})

Powyższy proces jest bardzo podobny do normalnego bloku try/catch w języku JavaScript. Błędy, które wystąpią w bloku „try”, natychmiast przechodzą do bloku catch(). Oto powyższe informacje w formie schematu blokowego (ponieważ uwielbiam schematy blokowe):

Niebieskie linie oznaczają obietnice, które zostały spełnione, a czerwone – te, które zostały odrzucone.

Wyjątki i obietnice JavaScriptu

Odrzucenia występują, gdy obietnica zostanie odrzucona jawnie, ale także niejawnie, jeśli w wywołaniu zwrotnym konstruktora zostanie zgłoszony błąd:

var jsonPromise = new Promise(function(resolve, reject) {
  // JSON.parse throws an error if you feed it some
  // invalid JSON, so this implicitly rejects:
  resolve(JSON.parse("This ain't JSON"));
});

jsonPromise.then(function(data) {
  // This never happens:
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

Oznacza to, że warto wykonywać wszystkie zadania związane z obietnicami w ramach wywołania zwrotnego konstruktora obietnic, aby błędy były automatycznie wychwytywane i odrzucane.

To samo dotyczy błędów zgłaszanych w wywołaniach zwrotnych then().

get('/').then(JSON.parse).then(function() {
  // This never happens, '/' is an HTML page, not JSON
  // so JSON.parse throws
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

Obsługa błędów w praktyce

W przypadku naszej historii i rozdziałów możemy użyć metody catch, aby wyświetlić użytkownikowi błąd:

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  addHtmlToPage(chapter1.html);
}).catch(function() {
  addTextToPage("Failed to show chapter");
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

Jeśli pobieranie story.chapterUrls[0] się nie powiedzie (np. błąd HTTP 500 lub użytkownik jest offline), pominięte zostaną wszystkie kolejne wywołania zwrotne, w tym to w getJSON(), które próbuje przeanalizować odpowiedź jako JSON, a także wywołanie zwrotne, które dodaje chapter1.html do strony. Zamiast tego przechodzi do wywołania zwrotnego catch. W efekcie, jeśli którekolwiek z poprzednich działań się nie powiedzie, na stronie zostanie dodany komunikat „Nie udało się wyświetlić rozdziału”.

Podobnie jak w przypadku try/catch w JavaScript, błąd jest przechwytywany, a kolejny kod jest wykonywany, więc ikona oczekiwania jest zawsze ukryta, co jest pożądanym zachowaniem. Powyższy kod staje się nieblokującą wersją asynchroniczną:

try {
  var story = getJSONSync('story.json');
  var chapter1 = getJSONSync(story.chapterUrls[0]);
  addHtmlToPage(chapter1.html);
}
catch (e) {
  addTextToPage("Failed to show chapter");
}
document.querySelector('.spinner').style.display = 'none'

Możesz użyć metody catch() tylko do rejestrowania, bez przywracania stanu po błędzie. Aby to zrobić, po prostu ponownie zgłoś błąd. Możemy to zrobić w metodzie getJSON():

function getJSON(url) {
  return get(url).then(JSON.parse).catch(function(err) {
    console.log("getJSON failed for", url, err);
    throw err;
  });
}

Udało nam się pobrać jeden rozdział, ale chcemy pobrać wszystkie. Zróbmy to.

Równoległość i sekwencjonowanie: jak najlepiej wykorzystać obie te metody

Myślenie asynchroniczne nie jest łatwe. Jeśli masz problem z rozpoczęciem, spróbuj napisać kod tak, jakby był synchroniczny. W tym przypadku:

try {
  var story = getJSONSync('story.json');
  addHtmlToPage(story.heading);

  story.chapterUrls.forEach(function(chapterUrl) {
    var chapter = getJSONSync(chapterUrl);
    addHtmlToPage(chapter.html);
  });

  addTextToPage("All done");
}
catch (err) {
  addTextToPage("Argh, broken: " + err.message);
}

document.querySelector('.spinner').style.display = 'none'

To działa. Jest jednak synchroniczny i blokuje przeglądarkę podczas pobierania. Aby to działało asynchronicznie, używamy then(), dzięki czemu zdarzenia następują po sobie.

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // TODO: for each url in story.chapterUrls, fetch & display
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

Ale jak możemy przejść przez adresy URL rozdziałów i pobrać je w odpowiedniej kolejności? To nie zadziała:

story.chapterUrls.forEach(function(chapterUrl) {
  // Fetch chapter
  getJSON(chapterUrl).then(function(chapter) {
    // and add it to the page
    addHtmlToPage(chapter.html);
  });
})

forEach nie obsługuje asynchroniczności, więc rozdziały pojawiałyby się w kolejności pobierania, czyli tak, jak napisano Pulp Fiction. To nie jest Pulp Fiction, więc naprawmy to.

Tworzenie sekwencji

Chcemy przekształcić tablicę chapterUrls w sekwencję obietnic. Możemy to zrobić za pomocą then():

// Start off with a promise that always resolves
var sequence = Promise.resolve();

// Loop through our chapter urls
story.chapterUrls.forEach(function(chapterUrl) {
  // Add these actions to the end of the sequence
  sequence = sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
})

Po raz pierwszy widzimy Promise.resolve(), który tworzy obietnicę, która rozwiązuje się do dowolnej wartości, którą jej nadasz. Jeśli przekażesz mu instancję Promise, po prostu ją zwróci (uwaga: jest to zmiana w specyfikacji, której niektóre implementacje jeszcze nie uwzględniają). Jeśli przekażesz mu coś podobnego do obietnicy (ma metodę then()), utworzy on prawdziwy obiekt Promise, który spełnia lub odrzuca obietnicę w ten sam sposób. Jeśli przekażesz inną wartość, np. Promise.resolve('Hello'), zostanie utworzona obietnica, która zostanie spełniona z tą wartością. Jeśli wywołasz ją bez wartości, jak powyżej, zostanie spełniona z wartością „undefined”.

Jest też Promise.reject(val), która tworzy obietnicę odrzucającą podaną wartość (lub niezdefiniowaną).

Powyższy kod możemy uporządkować za pomocą array.reduce:

// Loop through our chapter urls
story.chapterUrls.reduce(function(sequence, chapterUrl) {
  // Add these actions to the end of the sequence
  return sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
}, Promise.resolve())

Działa to tak samo jak w poprzednim przykładzie, ale nie wymaga osobnej zmiennej „sequence”. Wywołanie zwrotne reduce jest wywoływane dla każdego elementu tablicy. Za pierwszym razem „sequence” ma wartość Promise.resolve(), ale w przypadku pozostałych wywołań „sequence” to wartość zwrócona przez poprzednie wywołanie. array.reduce jest bardzo przydatna do sprowadzania tablicy do pojedynczej wartości, którą w tym przypadku jest obietnica.

Podsumujmy:

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  return story.chapterUrls.reduce(function(sequence, chapterUrl) {
    // Once the last chapter's promise is done…
    return sequence.then(function() {
      // …fetch the next chapter
      return getJSON(chapterUrl);
    }).then(function(chapter) {
      // and add it to the page
      addHtmlToPage(chapter.html);
    });
  }, Promise.resolve());
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

I mamy w pełni asynchroniczną wersję kodu synchronicznego. Możemy jednak zrobić więcej. Obecnie nasza strona pobiera się w ten sposób:

Przeglądarki dobrze radzą sobie z pobieraniem wielu rzeczy naraz, więc pobieranie rozdziałów jeden po drugim powoduje spadek wydajności. Chcemy pobrać je wszystkie jednocześnie, a następnie przetworzyć, gdy wszystkie zostaną dostarczone. Na szczęście istnieje interfejs API, który może w tym pomóc:

Promise.all(arrayOfPromises).then(function(arrayOfResults) {
  //...
})

Promise.all przyjmuje tablicę obietnic i tworzy obietnicę, która jest spełniana, gdy wszystkie obietnice zostaną pomyślnie zrealizowane. Otrzymasz tablicę wyników (niezależnie od tego, co obietnice spełniły) w tej samej kolejności co przekazane obietnice.

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // Take an array of promises and wait on them all
  return Promise.all(
    // Map our array of chapter urls to
    // an array of chapter json promises
    story.chapterUrls.map(getJSON)
  );
}).then(function(chapters) {
  // Now we have the chapters jsons in order! Loop through…
  chapters.forEach(function(chapter) {
    // …and add to the page
    addHtmlToPage(chapter.html);
  });
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened so far
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

W zależności od połączenia może to być o kilka sekund szybsze niż wczytywanie pojedynczo, a kod jest krótszy niż w naszej pierwszej próbie. Rozdziały mogą pobierać się w dowolnej kolejności, ale na ekranie pojawiają się we właściwej.

Możemy jednak poprawić postrzeganą wydajność. Gdy otrzymamy rozdział 1, powinniśmy dodać go do strony. Dzięki temu użytkownik może zacząć czytać, zanim dotrą pozostałe rozdziały. Gdy pojawi się rozdział 3, nie dodamy go do strony, ponieważ użytkownik może nie zdawać sobie sprawy, że brakuje rozdziału 2. Gdy pojawi się rozdział 2, możemy dodać rozdziały 2 i 3 itd.

W tym celu pobieramy pliki JSON dla wszystkich rozdziałów jednocześnie, a następnie tworzymy sekwencję, aby dodać je do dokumentu:

getJSON('story.json')
.then(function(story) {
  addHtmlToPage(story.heading);

  // Map our array of chapter urls to
  // an array of chapter json promises.
  // This makes sure they all download in parallel.
  return story.chapterUrls.map(getJSON)
    .reduce(function(sequence, chapterPromise) {
      // Use reduce to chain the promises together,
      // adding content to the page for each chapter
      return sequence
      .then(function() {
        // Wait for everything in the sequence so far,
        // then wait for this chapter to arrive.
        return chapterPromise;
      }).then(function(chapter) {
        addHtmlToPage(chapter.html);
      });
    }, Promise.resolve());
}).then(function() {
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

I gotowe – najlepsze z obu światów. Dostarczenie całej zawartości zajmuje tyle samo czasu, ale użytkownik szybciej otrzymuje jej pierwszą część.

W tym prostym przykładzie wszystkie rozdziały docierają mniej więcej w tym samym czasie, ale korzyści z wyświetlania ich pojedynczo będą bardziej widoczne przy większej liczbie większych rozdziałów.

Wykonanie powyższych czynności za pomocą wywołań zwrotnych lub zdarzeń w stylu Node.js wymaga około 2 razy więcej kodu, ale co ważniejsze, nie jest tak łatwe do prześledzenia. Jednak to nie koniec historii obietnic. W połączeniu z innymi funkcjami ES6 stają się one jeszcze łatwiejsze.

Runda bonusowa: rozszerzone możliwości

Od czasu, gdy napisałem ten artykuł, możliwości korzystania z obiektów Promise znacznie się rozszerzyły. Od wersji Chrome 55 funkcje asynchroniczne umożliwiają pisanie kodu opartego na obietnicach tak, jakby był on synchroniczny, ale bez blokowania głównego wątku. Więcej informacji na ten temat znajdziesz w moim artykule o funkcjach asynchronicznych. Zarówno obiekty Promise, jak i funkcje asynchroniczne są powszechnie obsługiwane w głównych przeglądarkach. Szczegółowe informacje znajdziesz w dokumentacji MDN dotyczącej Promise i funkcji asynchronicznej.

Dziękujemy Anne van Kesteren, Domenicowi Denicola, Tomowi Ashworthowi, Remy'emu Sharpowi, Addy'emu Osmani, Arthurowi Evansowi i Yutace Hirano za korektę i poprawki/zalecenia.

Dziękujemy też Mathiasowi Bynensowi za zaktualizowanie różnych części artykułu.