Obietnice kodu JavaScript: wprowadzenie

Obietnice upraszczają odroczone 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.

[Werbel]

Obietnice w JavaScript!

[Wybuchają fajerwerki, z góry sypie się brokatowy papier, tłum szaleje]

W tym momencie możesz należeć do jednej z tych kategorii:

  • Wokół Ciebie ludzie wiwatują, ale nie wiesz, o co chodzi. Może nawet nie wiesz, co to jest „obietnica”. Wzruszasz ramionami, ale ciężar błyszczącego papieru ciąży Ci na ramionach. Jeśli tak jest, nie przejmuj się tym. Zrozumienie, dlaczego warto się tym zajmować, zajęło mi sporo czasu. Najlepiej zacząć od początku.
  • Zacznij machać rękami. Najwyższy czas, prawda? Używasz tych obiektów Promise od jakiegoś czasu, ale przeszkadza Ci, że wszystkie implementacje mają nieco inny interfejs API. Jaki jest interfejs API oficjalnej wersji JavaScript? Zacznij od terminologii.
  • Wiedziałeś(-aś) o tym już wcześniej i z pogardą patrzysz na tych, którzy skaczą z radości, jakby to była dla nich nowość. Ciesz się chwilą triumfu, a potem przejdź do dokumentacji interfejsu API.

Obsługa przeglądarek i wypełnienie

Browser Support

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

Source

Aby dostosować przeglądarki, w których nie ma 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 w tym wszystkim chodzi?

JavaScript jest językiem jednowątkowym, co oznacza, że 2 fragmenty skryptu nie mogą być wykonywane w tym samym czasie. 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. Zazwyczaj jednak JavaScript znajduje się w tej samej kolejce co malowanie, aktualizowanie stylów i obsługa działań użytkownika (takich jak wyróżnianie tekstu i interakcja z elementami sterującymi formularza). Aktywność w jednym z tych elementów opóźnia pozostałe.

Jako człowiek jesteś wielowątkowy. Możesz pisać wieloma palcami, prowadzić samochód i rozmawiać jednocześnie. Jedyną funkcją blokującą, z którą mamy do czynienia, jest kichanie, podczas którego cała bieżąca aktywność musi zostać zawieszona na czas trwania kichnięcia. Jest to dość irytujące, zwłaszcza gdy prowadzisz samochód i próbujesz rozmawiać. Nie chcesz pisać kodu, który jest podatny na błędy.

Prawdopodobnie używasz zdarzeń i wywołań zwrotnych, aby obejść ten problem. 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 kichanie. Pobieramy obraz, dodajemy kilka odbiorników, a następnie JavaScript może przestać działać, dopóki nie zostanie wywołany jeden z tych odbiorników.

W podanym wyżej przykładzie zdarzenia mogły wystąpić, zanim zaczęliśmy ich nasłuchiwać. Musimy więc 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 wykrywa to obrazów, które spowodowały błąd, zanim zdążyliśmy je sprawdzić. Niestety DOM nie daje nam takiej możliwości. Poza tym wczytuje on jeden obraz. Sytuacja staje się jeszcze bardziej skomplikowana, gdy chcemy wiedzieć, kiedy załadował się zestaw obrazów.

Zdarzenia nie zawsze są najlepszym rozwiązaniem

Zdarzenia są przydatne w przypadku działań, które mogą wystąpić wiele razy w tym samym obiekcie – keyup, touchstart itp. W przypadku tych zdarzeń nie musisz się martwić tym, co się wydarzyło, zanim dołączysz odbiornik. W przypadku asynchronicznego sukcesu lub błędu najlepiej byłoby, gdyby wyglądało to tak:

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

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

Tak właśnie działają obietnice, ale z lepszymi nazwami. Gdyby elementy graficzne HTML miały metodę „ready”, która zwraca obietnicę, moglibyśmy to 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ń, z tą różnicą, że:

  • Obietnica może zostać spełniona lub niespełniona tylko raz. Nie może się udać ani nie udać dwa razy, ani nie może przejść z powodzenia do niepowodzenia ani odwrotnie.
  • Jeśli obietnica została spełniona lub odrzucona, a później dodasz wywołanie zwrotne w przypadku powodzenia lub niepowodzenia, zostanie wywołane odpowiednie wywołanie zwrotne, nawet jeśli zdarzenie miało miejsce wcześniej.

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

Terminologia dotycząca obietnic

Domenic Denicola sprawdził pierwszą wersję tego artykułu i ocenił mnie na „F” za terminologię. Zatrzymał mnie po lekcjach, zmusił do 100-krotnego przepisania States and Fates i napisał zaniepokojony 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 – nie udało się wykonać działania związanego z obietnicą.
  • oczekuje – nie została jeszcze zrealizowana ani odrzucona.
  • settled – zrealizowano lub odrzucono.

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

Obietnice w JavaScript!

Obietnice są dostępne od dłuższego czasu w postaci bibliotek, takich jak:

Powyższe obietnice i obietnice JavaScriptu mają wspólne, standardowe zachowanie zwane Promises/A+. Jeśli korzystasz z jQuery, istnieje podobna funkcja o nazwie Deferreds. Obiekty Deferred nie są jednak zgodne ze specyfikacją Promise/A+, co sprawia, że są nieco inne i mniej przydatne, więc należy zachować ostrożność. jQuery ma też typ Promise, ale jest to tylko podzbiór obiektu Deferred i ma te same problemy.

Chociaż implementacje obietnic są zgodne ze standardowym zachowaniem, ich interfejsy API różnią się od siebie. Obietnice JavaScript są podobne pod względem interfejsu API 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 Promise przyjmuje 1 argument, czyli wywołanie zwrotne z 2 parametrami: resolve i reject. Wykonaj w wywołaniu zwrotnym jakąś czynność, być może asynchroniczną, a potem wywołaj funkcję resolve, jeśli wszystko się udało, lub funkcję reject, jeśli wystąpił błąd.

Podobnie jak w przypadku throw w zwykłym JavaScript, odrzucenie za pomocą obiektu Error jest zwyczajowe, ale nie wymagane. Zaletą obiektów błędu jest to, że rejestrują one ślad stosu, co ułatwia korzystanie z narzędzi do debugowania.

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 wywołanie zwrotne w przypadku niepowodzenia. Oba są opcjonalne, więc możesz dodać wywołanie zwrotne tylko w przypadku powodzenia lub niepowodzenia.

Obietnice JavaScriptu zaczęły się w DOM jako „Futures”, zostały przemianowane na „Promises” i ostatecznie przeniesione do JavaScriptu. Umieszczenie ich w JavaScript zamiast w DOM jest korzystne, ponieważ będą 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 już inna kwestia).

Chociaż są one funkcją JavaScriptu, DOM nie boi się ich używać. Wszystkie nowe interfejsy DOM API z asynchronicznymi metodami sukcesu/błędu będą korzystać z obietnic. Dzieje się to już w przypadku zarządzania limitami, zdarzeń ładowania czcionek, ServiceWorker, Web MIDI, Streams i innych.

Zgodność z innymi bibliotekami

Interfejs JavaScript Promises API traktuje wszystko, co ma metodę then(), jako obiekt podobny do obietnicy (lub thenable w języku obietnic wzdycha). Jeśli więc używasz biblioteki, która zwraca obietnicę Q, wszystko jest w porządku – będzie ona dobrze współpracować z nowymi obietnicami JavaScript.

Jak już wspomniałem, obiekty Deferred w jQuery są trochę… nieprzydatne. Możesz jednak przekształcić je w standardowe obietnice, co warto zrobić jak najszybciej:

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

W tym przypadku funkcja $.ajax jQuery zwraca obiekt Deferred. Ponieważ ma metodę then(), Promise.resolve() może przekształcić ją w obietnicę JavaScript. Czasami jednak obiekty Deferred przekazują do funkcji zwrotnych wiele argumentów, np.:

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

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

Obietnice JS ignorują wszystkie oprócz pierwszego:

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

Na szczęście zwykle o to Ci chodzi lub przynajmniej daje Ci to dostęp do tego, czego szukasz. Pamiętaj też, że jQuery nie jest zgodny z konwencją przekazywania obiektów Error do odrzuceń.

Łatwiejsze tworzenie złożonego kodu asynchronicznego

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

  1. Uruchom wskaźnik postępu, aby wskazać ładowanie.
  2. Pobierz plik JSON z informacjami o opowiadaniu, który zawiera tytuł i adresy URL poszczególnych rozdziałów.
  3. Dodawanie tytułu strony
  4. Pobieranie każdego rozdziału
  5. Dodawanie relacji do strony
  6. Zatrzymaj spinner

… ale też poinformuj użytkownika, jeśli coś poszło nie tak. W tym momencie musimy też zatrzymać spinner, bo inaczej będzie się kręcić dalej, aż się zakręci w głowie i zderzy z innym elementem interfejsu.

Oczywiście nie użyjesz JavaScriptu do wyświetlania artykułu, ponieważ wyświetlanie w formacie HTML jest szybsze, ale ten wzorzec jest dość powszechny w przypadku interfejsów API: wielokrotne pobieranie danych, a następnie wykonanie jakiejś czynności po zakończeniu pobierania.

Zacznijmy od pobierania danych z sieci:

Przekształcanie XMLHttpRequest w obietnice

Stare interfejsy API zostaną zaktualizowane, aby używać obietnic, jeśli będzie to możliwe w sposób zapewniający zgodność wsteczną. XMLHttpRequest to doskonały kandydat, ale na razie napiszmy prostą funkcję do wysyłania żądań 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 rzadziej muszę widzieć irytujące pisanie wielbłądzie XMLHttpRequest, tym szczęśliwsze będzie moje życie.

Łączenie

then() to nie koniec historii. Możesz połączyć ze sobą kilka 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ć, po prostu 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 zmodyfikować naszą funkcję get, aby używała JSON responseType, ale możemy też rozwiązać ten problem w przypadku obietnic:

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

Ponieważ funkcja JSON.parse() przyjmuje 1 argument i zwraca przekształconą wartość, możemy zastosować skrót:

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

Funkcję getJSON() możemy utworzyć w bardzo prosty sposób:

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ż łączyć then, aby uruchamiać działania asynchroniczne w sekwencji.

Gdy zwracasz coś z wywołania zwrotnego then(), jest to trochę magiczne. 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 czekać na jej rozstrzygnięcie (powodzenie lub niepowodzenie) i zostanie wywołana dopiero wtedy. 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 tutaj asynchroniczne żądanie do story.json, które zwraca zestaw adresów URL do wysłania żądań. Następnie wysyłamy żądanie do pierwszego z nich. Wtedy obietnice zaczynają się wyróżniać na tle prostych wzorców wywołań zwrotnych.

Możesz nawet utworzyć skrót do uzyskiwania 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 przy kolejnych wywołaniach funkcji getChapter ponownie wykorzystujemy obietnicę historii, więc story.json jest pobierana tylko raz. Yay Promises!

Obsługa błędów

Jak widzieliśmy wcześniej, funkcja then() przyjmuje 2 argumenty: jeden w przypadku powodzenia, a drugi w przypadku niepowodzenia (lub spełnienia i odrzucenia, w terminologii 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 jest niczym szczególnym, to tylko cukier dla then(undefined, func), ale jest bardziej czytelny. Pamiętaj, że oba przykłady kodu powyżej nie działają tak samo. Drugi z nich jest odpowiednikiem tego kodu:

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 są pomijane i przechodzą do następnego then() z wywołaniem zwrotnym odrzucenia (lub catch(), ponieważ jest to równoważne). W przypadku then(func1, func2) wywoływana będzie funkcja func1 lub func2, ale nigdy obie. W przypadku then(func1).catch(func2) oba wywołania zostaną wykonane, jeśli func1 odrzuci żądanie, ponieważ są to oddzielne kroki w łańcuchu. Zrób zdjęcie:

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 JavaScript. Błędy występujące w bloku „try” są natychmiast przekazywane do bloku catch(). Poniżej znajdziesz ten proces w formie schematu blokowego (bo uwielbiam schematy blokowe):

Podążaj za niebieskimi liniami, aby zobaczyć obietnice, które zostały spełnione, lub za czerwonymi, aby zobaczyć te, które zostały odrzucone.

Wyjątki i obietnice w JavaScript

Odrzucenia występują, gdy obietnica jest wyraźnie odrzucona, 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 wszystkie działania związane z obietnicą warto wykonywać w wywołaniu zwrotnym konstruktora obietnicy, aby błędy były automatycznie wykrywane i przekształcane w odrzucenia.

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

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 naszej historii i rozdziałach możemy użyć bloku 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. wystąpi błąd HTTP 500 lub użytkownik jest offline), pominie wszystkie kolejne wywołania zwrotne, w tym wywołanie w getJSON(), które próbuje przeanalizować odpowiedź jako JSON, a także wywołanie zwrotne, które dodaje do strony plik chapter1.html. Zamiast tego przechodzi do funkcji zwrotnej catch. W rezultacie do strony zostanie dodany komunikat „Nie udało się wyświetlić rozdziału”, jeśli którekolwiek z poprzednich działań zakończyło się niepowodzeniem.

Podobnie jak w przypadku instrukcji try/catch w JavaScript błąd jest przechwytywany, a dalszy kod jest wykonywany, więc spinner jest zawsze ukryty, co jest pożądane. Powyższy kod staje się nieblokującą asynchroniczną wersją tego kodu:

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 to zrobić catch() tylko na potrzeby rejestrowania, bez przywracania stanu po błędzie. Aby to zrobić, po prostu ponownie zgłoś błąd. Możemy to zrobić w ramach naszej 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ć jeden rozdział, ale chcemy pobrać wszystkie. Zróbmy to.

Równoległość i sekwencjonowanie: jak wykorzystać zalety obu tych metod

Myślenie asynchroniczne nie jest łatwe. Jeśli masz problem z rozpoczęciem 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. Synchronizuje się jednak i blokuje przeglądarkę podczas pobierania. Aby to działało asynchronicznie, używamy then(), aby działania były wykonywane po kolei.

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ść w pętli przez adresy URL rozdziałów i pobrać je w odpowiedniej kolejności? To 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 rozdziały będą się wyświetlać w kolejności pobierania, czyli tak, jak powstał film Pulp Fiction. To nie jest Pulp Fiction, więc naprawmy to.

Tworzenie sekwencji

Chcemy przekształcić naszą chapterUrls tablicę 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óre tworzy obietnicę, która przyjmuje wartość podaną przez Ciebie. 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 prawdziwy obiekt Promise, który zostanie spełniony lub odrzucony w ten sam sposób. Jeśli przekażesz inną wartość, np. Promise.resolve('Hello') tworzy obietnicę, która jest spełniana z tą wartością. Jeśli wywołasz ją bez wartości, jak powyżej, zwróci „undefined”.

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

Powyższy kod możemy uprościć 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())

Działa to tak samo jak w poprzednim przykładzie, ale nie wymaga osobnej zmiennej „sequence”. Funkcja wywołania zwrotnego reduce jest wywoływana dla każdego elementu tablicy. „sequence” to Promise.resolve() za pierwszym razem, ale w przypadku pozostałych wywołań „sequence” to wartość zwrócona w poprzednim wywołaniu. array.reduce jest bardzo przydatna do sprowadzania tablicy do jednej 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';
})

I gotowe. Mamy w pełni asynchroniczną wersję wersji synchronicznej. Ale możemy zrobić to lepiej. Obecnie nasza strona pobiera się w ten sposób:

Przeglądarki dobrze radzą sobie z pobieraniem wielu elementów naraz, więc pobieranie rozdziałów jeden po drugim powoduje utratę wydajności. Chcemy pobrać je wszystkie jednocześnie, a potem przetworzyć, gdy już dotrą. Na szczęście istnieje interfejs API, który to umożliwia:

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

Funkcja Promise.all przyjmuje tablicę obietnic i tworzy obietnicę, która zostanie spełniona, gdy wszystkie obietnice zostaną wykonane. Otrzymasz tablicę wyników (zgodnie z obietnicami) 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 elementów, a kod jest krótszy niż w pierwszej próbie. Rozdziały mogą się pobierać w dowolnej kolejności, ale na ekranie będą wyświetlane w odpowiedniej kolejności.

Możemy jednak poprawić postrzeganą wydajność. Gdy pojawi się rozdział pierwszy, 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ł trzeci, nie dodamy go do strony, ponieważ użytkownik może nie zauważyć, że brakuje rozdziału drugiego. Gdy pojawi się rozdział drugi, możemy dodać rozdziały drugi i trzeci itd.

W tym celu pobieramy jednocześnie pliki JSON wszystkich rozdziałów, 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. To najlepsze z obu światów. Dostarczenie wszystkich treści zajmuje tyle samo czasu, ale użytkownik szybciej otrzymuje pierwszą część 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 ich po kolei będą większe w przypadku większej liczby dłuższych rozdziałów.

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

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 55 przeglądarki Chrome funkcje asynchroniczne umożliwiają pisanie kodu opartego na obietnicach tak, jakby był synchroniczny, ale bez blokowania głównego wątku. Więcej informacji znajdziesz w artykule o funkcjach asynchronicznych. W najpopularniejszych przeglądarkach jest szeroko obsługiwana zarówno funkcja Promises, jak i funkcje asynchroniczne. Szczegółowe informacje znajdziesz w dokumentacji MDN dotyczącej obiektu Promisefunkcji asynchronicznej.

Dziękujemy Anne van Kesteren, Domenicowi Denicoli, Tomowi Ashworthowi, Remy’emu Sharpowi, Addy’emu Osmaniemu, Arthurowi Evansowi i Yutace Hirano za sprawdzenie tego artykułu i wprowadzenie poprawek oraz zaproponowanie zmian.

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