Obietnice kodu JavaScript: wprowadzenie

Obietnice upraszczają opóźnione i asynchroniczne obliczenia. 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ę perkusja]

W języku JavaScript pojawiły się obietnice!

[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 dopingują Cię, ale nie wiesz, o co cię 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? Poprosiłeś o skorzystanie z funkcji Promise, ale irytuje Cię, że wszystkie implementacje mają 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 skaczą z radości, jakby to była 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 JavaScript dzieli wątek z kolejnymi elementami, które różnią się w zależności od przeglądarki. Zazwyczaj jednak 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 formularza). Aktywność w jednym z tych obszarów opóźnia pozostałe.

Jako człowiek masz wielowątkowość. 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 korzystasz z funkcji zdarzeń i wywołań zwrotnych, aby ominąć 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 kichać. 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 itp.). W przypadku tych zdarzeń nie ma znaczenia, co działo się przed dołączeniem odbiornika. Jeśli chodzi o asyncjoniczne wyniki sukcesu/błędu, 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” zwracającą obietnicę, moglibyśmy wykonać tę czynność:

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ę udać lub nie powieść tylko raz. Nie może ona zakończyć się powodzeniem ani niepowodzeniem dwukrotnie, ani nie może przejść z powodu na niepowodzenie lub odwrotnie.
  • Jeśli obietnica została zrealizowana lub nie powiodła się, a później dodasz wywołanie zwrotne o sukcesie lub niepowodzeniu, zostanie wywołane prawidłowe wywołanie zwrotne, mimo że 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, dowód na to, że przeczytał pierwszą wersję roboczą tego artykułu, ocenił mnie jako „F” za terminologię. Umieszczając mnie w więzieniu, zmusił do skopiowania 100 razy Stanów i losów i napisał zmartwiony list do rodziców. Mimo to rozumiem sporo terminologii, ale oto podstawowe informacje:

Obietnice mogą być:

  • fulfill – działanie związane z obietnicą zostało zrealizowane.
  • rejected – działanie powiązane z obietnicą nie powiodło się
  • 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 przypominającego obietnicę, ponieważ zawiera metodę then. Ten termin przypomina mi byłego menedżera Anglii, Terry'ego Venablesa, więc będę go używać jak najczęściej.

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, standardowe zachowanie, nazywane obietnicami/A+. Jeśli używasz jQuery, masz coś podobnego, czyli opóźnienia. 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.

Implementacje obiecujące działają w standardowy sposób, jednak ich ogólne interfejsy API różnią się od siebie. Obietnice JavaScript są podobne w interfejsie 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 obietnicy przyjmuje 1 argument, funkcję wywołania zwrotnego z 2 parametrami: resolve i reject. Wykonaj działanie w wywołaniu zwrotnym (na przykład asynchronicznym), a następnie wywołaj rozstrzygnięcie, jeśli wszystko zadziałało. W przeciwnym razie wywołaj odrzucenie.

Podobnie jak w zwykłym JavaScriptzie, throw, odrzucenie z obiektem Error jest zwyczajowe, ale nie jest wymagane. Zaletą obiektów Error jest to, że przechwytują one ścieżkę stosu, dzięki czemu narzędzia do debugowania są bardziej przydatne.

Jak wykorzystać tę obietnicę:

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 drugi dla 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 obiecujące (lub thenable w języku obietnic sigh), więc jeśli używasz biblioteki, która zwraca obietnicę Q, to 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 stosować do standardowych obietnic, co warto zrobić jak najszybciej:

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

W tym przypadku właściwość $.ajax biblioteki jQuery zwraca wartość Deferred (odroczona). Ponieważ ma metodę then(), Promise.resolve() może przekształcić ją w obietnicę 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, zakodujmy parę rzeczy. Załóżmy, że chcemy:

  1. Uruchom wskaźnik postępu, aby wskazać wczytywanie.
  2. Pobierz plik JSON z opowiadaniem, który zawiera tytuł i adresy URL poszczególnych rozdziałów.
  3. Dodaj tytuł 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ę zakręci i wpadnie na inne UI.

Oczywiście w przypadku tworzenia artykułów w języku JavaScript nie nadaje się oczywiście JavaScript, ponieważ wyświetlanie stron w formacie HTML jest szybsze, ale w przypadku interfejsów API ten wzorzec jest dość powszechny: wielokrotne pobieranie danych, a potem działanie w dowolnej chwili.

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

Obiecywanie XMLHttpRequest

Stare interfejsy API zostaną zaktualizowane w celu korzystania z obietnic, jeśli jest to możliwe w sposób zapewniający zgodność wsteczną. 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żna wysyłać żądania HTTP bez ręcznego wpisywania XMLHttpRequest – i to świetnie, bo im mniej bliższej nauki w XMLHttpRequest, tym szczęśliwsze będzie moje życie.

Ł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

Możesz przekształcić wartości, zwracając im 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ą w postaci zwykłego tekstu. 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.

Zwracanie czegoś z wywołania zwrotnego then() jest prawdziwą magią. Jeśli zwrócisz wartość, wywołana jest kolejna funkcja then() z tą wartością. Jeśli jednak zwrócisz coś, co przypomina obietnicę, funkcja then() będzie na nią 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);
})

Pobieramy obiekt story.json dopiero wtedy, gdy funkcja getChapter zostanie wywołana, ale przy następnym wywołaniu funkcji getChapter wykorzystamy obietnicę historii, więc story.json jest pobierany 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. Zwróć uwagę, że 2 przykłady powyżej działają inaczej. Ten drugi 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. Odrzucenie obietnicy powoduje przejście do następnego elementu then() z wywołaniem zwrotnym odrzucenia (lub catch(), ponieważ jest odpowiednikiem). 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 proces jest bardzo podobny do normalnego mechanizmu testowania/przechwytywania JavaScriptu. Błędy występujące podczas próby „try” przechodzą od razu do bloku catch(). Oto powyżej w formie schematu blokowego (bo lubię schematy blokowe):

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

Wyjątki i obietnice 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 wszystkie zadania związane z obietnicami można wykonywać w ramach wywołania zwrotnego konstruktora obietnic, dzięki czemu błędy są automatycznie wychwytywane i stają się odrzuceniami.

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 naszej historii i rozdziałach za pomocą tagu możemy 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 nie uda się pobrać pliku story.chapterUrls[0] (np. HTTP 500 lub użytkownik jest offline), zostaną pominięte wszystkie kolejne udane wywołania zwrotne, w tym to z getJSON(), które próbuje przeanalizować odpowiedź jako JSON, oraz pomija wywołanie zwrotne, które dodaje do strony sekcję rozdział1.html. 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 ta funkcja działała asynchronicznie, używamy funkcji then() w celu umożliwienia sobie nawzajem wykonywania tych czynności.

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? 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 odcinki będą się wyświetlać w dowolnej kolejności, w której się pobiorą. W podstawie tak właśnie powstał Pulp Fiction. To nie jest Pulpfiction, więc poprawmy 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 zetknęliśmy się z funkcją Promise.resolve(), która tworzy obietnicę zgodną z Twoją 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 (czyli coś, co 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ć, używając 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() przy pierwszym wywołaniu, ale w przypadku kolejnych wywołań „sequence” to cokolwiek zwrócone przez poprzednie wywołanie. array.reduce jest bardzo przydatna do zredukowania 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';
})

I o to chodzi – w pełni asynchroniczną wersję wersji synchronizacji. Ale możemy zrobić więcej. W tej chwili nasza strona jest pobierana w następujący 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 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 z nich zostaną spełnione. 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 potrwać kilka sekund szybciej niż ładowanie pojedynczego i krótszy kod niż przy pierwszej próbie. Rozdziały można pobierać w dowolnej kolejności, ale na ekranie będą wyświetlane w prawidłowej kolejności.

Nadal możemy jednak 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. Dostarczenie wszystkich treści zajmuje tyle samo czasu, ale użytkownik zobaczy pierwszy fragment szybciej.

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.

Opisane powyżej wywołania zwrotne lub zdarzenia w stylu Node.js pozwalają podwoić kod, ale, co ważniejsze, nie są aż tak proste. Obietnice to jednak nie wszystko. W połączeniu z innymi funkcjami ES6 są jeszcze łatwiejsze w użyciu.

Bonusowa runda: dodatkowe możliwości

Od czasu napisania tego artykułu znacznie rozszerzyły się możliwości korzystania z niego. 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 Promise i funkcji asynchronicznej w MDN.

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.