Obietnice kodu JavaScript: wprowadzenie

Obietnice upraszczają opóźnione i asynchroniczne obliczenia. Obietnica reprezentuje operację, która nie została jeszcze ukończona.

Jake Archibald
Jake Archibald

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

[Zaczyna się dźwięk werbla]

Obietnice w JavaScript

[Wybuchające fajerwerki, spływające z góry papierowe konfetti, podekscytowany tłum]

W tej chwili mieścisz się w jednej z tych kategorii:

  • Ludzie wokół Ciebie się cieszą, ale nie wiesz, o co chodzi. Może nawet nie wiesz, co to jest „obietnica”. Wzruszasz ramionami, ale ciężar papieru z brokatem ciąży na Twoich barkach. Jeśli tak, nie martw się. Minęło wiele czasu, zanim zrozumiałem, dlaczego warto się tym przejmować. Zacznij od początku.
  • Wymierzasz cios w powietrze. W samą porę, prawda? Korzystałeś(-aś) już z obietnic, ale przeszkadza Ci to, że każda implementacja ma nieco inny interfejs API. Jaki jest interfejs API oficjalnej wersji JavaScript? Zacznij od zapoznania się z terminologią.
  • Wiesz już o tym i wyśmiewasz tych, którzy szaleją z radości, jakby to była dla nich nowość. Poczuj swoją wyższość, a potem przejdź do dokumentacji interfejsu API.

Obsługa w przeglądarce i polyfill

Obsługa przeglądarek

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

Źródło

Aby dostosować przeglądarki, które nie mają pełnej implementacji obietnic, do specyfikacji lub dodać obietnice do innych przeglądarek i Node.js, skorzystaj z polyfilla (2 KB w formacie skompresowanym).

O co w tym wszystkim chodzi?

Kod 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 kod JavaScript współdzieli wątek z wiele innymi elementami, które różnią się w zależności od przeglądarki. Jednak zazwyczaj JavaScript jest w tej samej kolejce co renderowanie, aktualizowanie stylów i obsługa działań użytkownika (np. wyróżnianie tekstu i interakcja z elementami sterującymi formularza). Aktywność w jednym z tych obszarów opóźnia pozostałe.

Jako człowiek masz wiele wątków. Możesz pisać na klawiaturze za pomocą wielu palców, a na dodatek możesz prowadzić rozmowę i prowadzić samochód jednocześnie. Jedyną blokującą funkcją jest kichanie, podczas którego należy wstrzymać wszystkie bieżące działania. To bardzo denerwujące, zwłaszcza gdy prowadzisz samochód i próbujesz prowadzić rozmowę. Nie chcesz pisać kodu, który jest słaby.

Prawdopodobnie używasz do tego zdarzeń i wywołań zwrotnych. Oto zdarzenia:

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 swędzenie. Pobieramy obraz, dodajemy kilka odbiorców, a JavaScript może przestać się wykonywać, dopóki nie zostanie wywołany jeden z tych odbiorców.

W przypadku tego przykładu może się zdarzyć, że zdarzenia miały miejsce, zanim zaczęliśmy je nasłuchiwać, więc musimy obejść ten problem, korzystając z 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
});

Nie obejmuje to obrazów, które zostały wyświetlone przed odsłuchaniem. Niestety DOM nie daje nam możliwości ich wykrycia. Poza tym wczytuje tylko 1 obraz. Sprawy komplikują się jeszcze bardziej, gdy chcemy wiedzieć, kiedy załaduje się zestaw obrazów.

Zdarzenia nie zawsze są najlepszym sposobem

Zdarzenia są przydatne w przypadku zdarzeń, które mogą wystąpić wielokrotnie w tym samym obiekcie (keyup, touchstart itd.). W przypadku tych zdarzeń nie ma znaczenia, co działo się przed dołączeniem odbiornika. Jeśli chodzi o sukces/niepowodzenie asynchroniczne, najlepiej jest mieć coś takiego:

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

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

Obietnice działają podobnie, ale mają lepszą nazwę. Gdyby elementy graficzne HTML miały metodę „ready”, która zwraca obietnicę, moglibyśmy zrobić to:

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

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

Na najprostszym poziomie obietnice są trochę podobne do detektorów zdarzeń, z tym że:

  • Obietnica może się powieść lub nie tylko raz. Nie może ona zakończyć się powodzeniem ani niepowodzeniem dwukrotnie, ani nie może przejść z powodzenia do niepowodzenia i na odwrót.
  • Jeśli obietnica zakończyła się powodzeniem lub niepowodzeniem, a później dodasz wywołanie zwrotne powodzenia/niepowodzenia, zostanie wywołane odpowiednie wywołanie zwrotne, nawet jeśli zdarzenie miało miejsce wcześniej.

Jest to bardzo przydatne w przypadku asynchronicznego sukcesu lub niepowodzenia, ponieważ nie interesuje Cię dokładny czas, w którym coś stało się dostępne, a bardziej reakcja na wynik.

Terminologia związana z usługą Promise

Domenic Denicola przeczytał pierwszą wersję tego artykułu i przypisał mi ocenę „F” za terminologię. Umieścił mnie w karcerze, zmusił do 100 razy przepisania States and Fates i napisał zaniepokojony list do moich rodziców. Mimo to nadal często mylę terminy. Oto podstawy:

Obietnice mogą być:

  • fulfilled (spełnione) – działanie związane z obietnicą zostało wykonane.
  • odrzucone – nie udało się wykonać działania związanego z obietnicą
  • oczekuje – prośba nie została jeszcze zrealizowana ani odrzucona;
  • settled (rozliczone) – zamówienie zostało zrealizowane lub odrzucone.

Specyfikacja używa też terminu thenable do opisania obiektu podobnego do obietnicy, który ma metodę then. Ten termin przywodzi mi na myśl byłego menedżera reprezentacji Anglii w piłce nożnej Terry’ego Venablesa, więc będę używać go jak najrzadziej.

Obietnice są realizowane w JavaScript.

Obietnice istnieją już od jakiegoś czasu w formie bibliotek, takich jak:

Obietnice JavaScript i obietnice opisane powyżej mają wspólne, ustandaryzowane zachowanie, nazywane obietnicami/A+. Jeśli używasz jQuery, masz coś podobnego, co nazywa się opóźnieniami. Jednak funkcje Deferred nie są zgodne z Promise/A+, co powoduje, że są nieco inne i mniej przydatne. Należy to mieć na uwadze. jQuery ma też typ Promise, ale jest to tylko podzbiór funkcji Deferred i ma te same problemy.

Chociaż implementacje obietnic są zgodne ze standardowym zachowaniem, ich ogólne interfejsy API się różnią. Obietnice JavaScript są podobne do RSVP.js w interfejsie API. 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, funkcję wywołania zwrotnego z 2 parametrami: resolve i reject. W wywołaniu zwrotnym wykonaj jakąś akcję, być może asynchroniczną, a potem wywołaj resolve, jeśli wszystko zadziała, lub reject.

Podobnie jak w zwykłym języku JavaScript, w przypadku throw jest zwyczajowo (choć nie jest to wymagane) odrzucanie za pomocą obiektu Error. Zaletą obiektów Error jest to, że przechwytują one ścieżkę stosu, dzięki czemu narzędzia do debugowania są bardziej przydatne.

Oto jak możesz wykorzystać to zobowiązanie:

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

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

Obietnice JavaScript w DOM początkowo nazywały się „Futures” (przyszłoście), później zmieniono ich nazwę na „Promises” (obietnice), a ostatecznie przeniesiono je do JavaScriptu. To, że są one dostępne w JavaScriptzie, a nie w DOM, jest bardzo przydatne, ponieważ będą dostępne w kontekstach JavaScriptu poza przeglądarką, takich jak Node.js (czy są one używane w podstawowych interfejsach API, to już inna kwestia).

Chociaż są to funkcje JavaScriptu, DOM chętnie z nich korzysta. W fakcie wszystkie nowe interfejsy DOM API z asyncjonalnymi metodami powodzenia/niepowodzenia będą używać obietnic. Dotyczy to już zarządzania limitami, zdarzeń wczytywania czcionek, ServiceWorker, Web MIDI, strumyków i innych.

Zgodność z innymi bibliotekami

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

Jak już wspomniałem, odroczone wywołania jQuery są trochę… nieprzydatne. Na szczęście możesz je przekształcić w standardowe obietnice. Warto zrobić to jak najszybciej:

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

W tym przypadku funkcja jQuery $.ajax zwraca obiekt Deferred. Ponieważ ma metodę then(), Promise.resolve() może przekształcić ją w obietnice JavaScript. Czasami jednak funkcje opóźnione przekazują do swoich funkcji zwrotnych kilka argumentów, na przykład:

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

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

Obietnice w JS ignorują wszystkie oprócz pierwszej:

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

Zwykle jest to pożądane, a co najmniej daje dostęp do tego, czego potrzebujesz. Pamiętaj też, że jQuery nie stosuje konwencji przekazywania obiektów Error do odrzuceń.

Upraszczanie złożonego kodu asynchronicznego

Dobrze, zacznijmy kodować. Załóżmy, że chcemy:

  1. Uruchom wskaźnik postępu, aby wskazać ładowanie.
  2. Pobierz plik JSON z opowiadaniem, który zawiera tytuł i adresy URL każdego rozdziału.
  3. Dodawanie tytułu strony
  4. Pobieranie każdego rozdziału
  5. Dodawanie artykułu do strony
  6. Zatrzymanie spinnera

…ale także poinformuj użytkownika, jeśli coś pójdzie nie tak. W tym momencie musimy też zatrzymać spinner, bo inaczej będzie się kręcić w nieskończoność, aż się zatrzyma, a potem wpadnie na inne elementy interfejsu.

Oczywiście nie używasz JavaScriptu do wyświetlania artykułu, ponieważ wyświetlanie w formie HTML jest szybsze, ale ten schemat jest dość powszechny w przypadku interfejsów API: najpierw pobieranie wielu danych, a potem wykonanie jakiejś czynności.

Na początek zajmijmy się pobieraniem danych z sieci:

Przekształcanie XMLHttpRequest w obiekt typu Promise

Stare interfejsy API zostaną zaktualizowane, aby używały obietnic, o ile jest to możliwe w sposób zgodny z wstecz. XMLHttpRequest jest najlepszym kandydatem, ale na razie napiszemy prostą funkcję do wysyłania żądania 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żyj 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ące znaki CamelCase w formie XMLHttpRequest, tym szczęśliwszy jestem.

Łańcuchowanie

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

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
})

Jako praktyczny przykład wrócimy do:

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ć naszą funkcję get, aby używała funkcji JSON responseType, ale możemy też rozwiązać ten problem w świecie obietnic:

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

Funkcja JSON.parse() przyjmuje 1 argument i zwraca przekształconą wartość, więc 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 potem analizuje odpowiedź jako JSON.

Umieszczanie w kolejce działań asynchronicznych

Możesz też łączyć then, aby wykonywać działania asynchroniczne sekwencyjnie.

Gdy zwrócisz coś z wywołania zwrotnego then(), to trochę jak magia. Jeśli zwracasz wartość, wywoływana jest następna funkcja then() z tą wartością. Jeśli jednak zwrócisz coś podobnego do obietnicy, funkcja then() będzie na to czekać i będzie wywoływana tylko wtedy, gdy obietnica zostanie spełniona (uda się lub zakończy się niepowodzeniem). Na przykład:

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

Tutaj wysyłamy asynchroniczne żądanie do usługi story.json, która zwraca nam zbiór adresów URL, a potem wysyłamy żądanie do pierwszego z nich. Wtedy obietnice naprawdę zaczynają się wyróżniać na tle prostych wzorców wywołania zwrotnego.

Możesz nawet utworzyć skrót do rozdziałów:

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 gdy następnym razem zostanie wywołana funkcja getChapter, ponownie użyjemy obietnicy dotyczącej historii, więc story.json zostanie pobrana tylko raz. Obietnice

Obsługa błędów

Jak już wspomnieliśmy, funkcja then() przyjmuje 2 argumenty: jeden dla powodzenia, drugi dla niepowodzenia (lub wypełnij i odrzuć 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);
})

catch() nie wyróżnia się niczym szczególnym, to tylko dodatek do then(undefined, func), ale jest bardziej czytelny. Pamiętaj, że te 2 przykłady kodu działają inaczej. Drugi przykład jest równoważny temu:

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 obietnicy pomijają następne then() z odrzuceniem z powrotem (lub catch(), ponieważ jest to równoważne). Jeśli then(func1, func2), func1 lub func2 zostanie wywołana, nigdy nie zostaną wywołane obie. W przypadku funkcji then(func1).catch(func2) obie funkcje zostaną wywołane, jeśli funkcja func1 zwróci wartość false, ponieważ są to oddzielne 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 przepływ danych jest bardzo podobny do zwykłego try/catch w JavaScript. Błędy występujące w bloku „try” są od razu przekazywane do bloku catch(). Oto powyższy schemat w postaci diagramu blokowego (bo uwielbiam diagramy blokowe):

Linie niebieskie wskazują obietnice, które zostały spełnione, a czerwone – te, które zostały odrzucone.

Wyjątki i obietnice w JavaScript

Odrzucenia występują, gdy obietnica jest wyraźnie odrzucana, ale także pośrednio, jeśli w sprawdzonym konstruktorze wystąpi 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 wykonać wszystkie operacje związane z obietnicami w ramach konstruktora obietnicy, aby błędy były automatycznie wykrywane i odrzucane.

To samo dotyczy błędów wyrzucanych w funkcjach zwracanych przez 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 historii i rozdziałów możemy użyć 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] zakończy się niepowodzeniem (np. błąd http 500 lub użytkownik jest offline), pominie wszystkie kolejne wywołania zwrotne sukcesu, w tym to w getJSON(), które próbuje przeanalizować odpowiedź jako JSON, a także pominie wywołanie zwrotne, które dodaje plik chapter1.html do strony. Zamiast tego przechodzi do wywołania zwrotnego catch. W związku z tym, jeśli któreś z tych działań nie powiedzie się, na stronie zostanie dodany komunikat „Nie udało się wyświetlić rozdziału”.

Podobnie jak w przypadku instrukcji try/catch w JavaScript, błąd jest przechwytywany, a następny kod jest kontynuowany, więc wskaźnik postępu jest zawsze ukryty, co jest pożądanym efektem. Powyższe wyrażenie staje się wersją asynchroniczną, która nie blokuje:

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ć funkcji catch() tylko do celów rejestrowania, bez odzyskiwania po błędzie. Aby to zrobić, ponownie wywołaj błąd. Możemy to zrobić za pomocą metody 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ć 1 rozdział, ale chcemy mieć wszystkie. Zróbmy to.

Równoległość i sekwencjonowanie: jak połączyć zalety obu tych metod

Myślenie asynchroniczne nie jest łatwe. Jeśli nie możesz się zabrać do pracy, 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. Ale synchronizuje i blokuje przeglądarkę podczas pobierania. Aby to działało asynchronicznie, używamy funkcji then(), która pozwala na wykonywanie zadań jedno po drugim.

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 kolejności? nie dział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 nasze rozdziały będą się wyświetlać w dowolnej kolejności, w której zostaną pobrane, co jest zasadniczo zgodne z metodą pisania scenariusza filmu Pulp Fiction. To nie 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 tutaj Promise.resolve(), co tworzy obietnicę, która jest realizowana w dowolnej wartości. Jeśli przekażesz mu instancję Promise, po prostu ją zwróci (uwaga: jest to zmiana specyfikacji, której niektóre implementacje jeszcze nie uwzględniają). Jeśli przekażesz mu coś na kształt obietnicy (ma metodę then()), tworzy prawdziwą Promise, która spełnia lub odrzuca w taki sam sposób. Jeśli podasz inną wartość, np. Promise.resolve('Hello'), tworzy obietnicę, która spełnia tę wartość. Jeśli wywołasz go bez wartości, jak wyżej, zwraca on wartość „undefined”.

Jest też Promise.reject(val), która tworzy obietnicę odrzucenia z wartością podaną przez Ciebie (lub nieokreśloną).

Powyższy kod możemy uporządkować za pomocą funkcji 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())

Ta funkcja działa tak samo jak w poprzednim przykładzie, ale nie wymaga osobnej zmiennej „sekwencja”. Nasz podprogowy callback jest wywoływany dla każdego elementu w tablicy. „sequence” to Promise.resolve() w pierwszym wywołaniu, ale w pozostałych wywołaniach „sequence” to wartość zwrócona przez poprzednie wywołanie. array.reduce jest bardzo przydatna do zawężenia tablicy do pojedynczej wartości, która w tym przypadku jest obietnicą.

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';
})

Oto ona: całkowicie asynchroniczna wersja wersji synchronicznej. Ale możemy zrobić więcej. Obecnie nasza strona pobiera się w ten sposób:

Przeglądarki całkiem dobrze radzą sobie z pobieraniem wielu rzeczy naraz, więc pobieranie rozdziałów jeden po drugim powoduje spadek wydajności. Chcemy je pobrać wszystkie w tym samym czasie, a potem przetworzyć, gdy wszystkie się pojawią. Na szczęście istnieje interfejs API, który to umożliwia:

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

Promise.all przyjmuje tablicę obietnic i tworzy obietnicę, która zostanie spełniona, gdy wszystkie z nich zostaną wykonane. Otrzymasz tablicę wyników (cokolwiek obiecuje spełnienie obietnic) w tej samej kolejności, w jakiej zostały 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 pojedynczych obrazów. Poza tym wymaga mniej kodu niż nasza pierwsza próba. Rozdziały można pobierać w dowolnej kolejności, ale na ekranie będą wyświetlane w prawidłowej kolejności.

Nadal jednak możemy poprawić postrzeganą skuteczność. Gdy otrzymamy pierwszy rozdział, powinniśmy go dodać do strony. Dzięki temu użytkownik może zacząć czytać, zanim pojawią się pozostałe rozdziały. Gdy pojawi się rozdział 3, nie dodamy go do strony, ponieważ użytkownik może nie zauważyć, że brakuje rozdziału 2. Gdy pojawi się rozdział 2, możemy dodać rozdziały 2 i 3 itd.

Aby to zrobić, pobieramy dane JSON dla wszystkich rozdziałów jednocześnie, a potem 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 to jest to – najlepsze z obu światów. Czas potrzebny na dostarczenie wszystkich treści jest taki sam, ale użytkownik szybciej otrzyma pierwszy fragment treści.

W tym prostym przykładzie wszystkie rozdziały pojawiają się mniej więcej w tym samym czasie, ale korzyści z wyświetlania po jednym rozdziale będą większe w przypadku większej liczby rozdziałów.

Wykonanie tych czynności za pomocą wyzwań zwrotnych lub zdarzeń w stylu Node.js powoduje podwojenie kodu, ale co ważniejsze, jest trudniejsze do zrozumienia. To jednak nie koniec historii obietnic. W połączeniu z innymi funkcjami ES6 są jeszcze łatwiejsze.

Runda bonusowa: dodatkowe możliwości

Od czasu napisania tego artykułu możliwości korzystania z obietnic znacznie się zwiększyły. Od wersji 55 przeglądarki Chrome funkcje asynchroniczne umożliwiają pisanie kodu opartego na obietnicach tak, jakby był on synchroniczny, ale bez blokowania wątku głównego. Więcej informacji znajdziesz w artykule o funkcjach asynchronicznych. W głównych przeglądarkach powszechnie obsługiwane są zarówno obietnice, jak i funkcje asynchroniczne. Szczegółowe informacje znajdziesz w dokumentacji MDN na temat obietnicfunkcji asynchronicznych.

Dziękujemy Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans i Yutaka Hirano za sprawdzenie tej wersji pod kątem poprawności i wprowadzenie poprawek oraz przedstawienie rekomendacji.

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