Obietnice kodu JavaScript: wprowadzenie

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

Jake Archibald
Jake Archibald

Przygotuj się na przełomowy moment w historii tworzenia stron internetowych.

[Zaczyna się perkusja]

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

[Wybuchają fajerwerki, z góry pada błyszczący papier, tłum szaleje]

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

  • Ludzie dopingują Cię, ale nie masz pewności, o co ci chodzi. Może nawet nie do końca wiesz, co to jest „obietnica”. Wzruszasz ramionami, ale ciężar błyszczącego papieru spowalnia Twoje ramiona. Jeśli tak, bez obaw. Czasem zajęło mi zastanowienie, dlaczego muszę zajmować się tymi sprawami. Prawdopodobnie chcesz zacząć od początku.
  • Świetnie Ci idzie! To w odpowiednim momencie, prawda? Poprosiłeś o skorzystanie z funkcji Promise, ale irytuje Cię, że wszystkie implementacje mają nieco inny interfejs API. Czym jest interfejs API dla oficjalnej wersji JavaScriptu? Najlepiej zacząć od terminologii.
  • Wiecie już o tym wcześniej i szydzimy się z tych, których podskakują, jakby to była wiadomość dla nich. Daj się pochłonąć swojej wyższości, a potem przejdź od razu do materiałów referencyjnych interfejsu API.

Obsługa przeglądarek i kod polyfill

Obsługa przeglądarek

  • 32
  • 12
  • 29
  • 8

Źródło

Aby zapewnić zgodność przeglądarek, których nie obiecujemy w pełni, lub dodać obietnice do innych przeglądarek i środowiska Node.js, warto wypróbować polyfill (2k gzipped).

O co chodzi?

JavaScript jest jednowątkowy, co oznacza, że nie mogą działać 2 bity skryptu w tym samym czasie – 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. Jednak zazwyczaj JavaScript znajduje się w tej samej kolejce co malowanie, aktualizowanie stylów i obsługa działań użytkowników (np. wyróżnianie tekstu i wchodzenie w interakcje z elementami sterującymi formularza). Aktywność w jednym z tych elementów opóźnia pozostałe.

Jako człowiek masz wielowątkowość. Możesz pisać kilkoma palcami oraz prowadzić i przytrzymać rozmowę jednocześnie. Jedyną funkcją blokującą, z którą musimy sobie poradzić, jest kichanie – wtedy wszystkie bieżące działania muszą być zawieszone na czas kichania. To dość denerwujące, zwłaszcza gdy prowadzisz samochód i próbujesz podtrzymać rozmowę. Na pewno nie chcesz pisać kodu, który kicha.

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 i dodajemy kilka detektorów, a JavaScript może przestać wykonywać, dopóki nie zostanie wywołany jeden z nich.

W powyższym przykładzie możliwe, że zdarzenia miały miejsce, zanim zaczęliśmy ich nasłuchiwać, więc musimy obejść ten problem, 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
});

Nie wychwytujemy obrazów, które zawierają błędy, zanim je wysłuchamy. Niestety DOM nie umożliwia tego. Spowoduje to załadowanie 1 obrazu. Sprawa staje się jeszcze bardziej złożona, gdy chcemy wiedzieć, kiedy zbiór obrazów został wczytany.

Wydarzenia nie zawsze są najlepszym sposobem

Zdarzenia świetnie sprawdzają się w przypadku zdarzeń, które mogą powtórzyć się wiele razy w tym samym obiekcie (keyup, touchstart itp.). W przypadku tych zdarzeń nie jest dla Ciebie istotne to, co wydarzyło się przed podłączeniem detektora. Jeśli jednak chodzi o asynchroniczny sukces lub niepowodzenie, warto rozważyć użycie takiego kodu:

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

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

Takie są obietnice, ale z lepszym nazewnictwa. 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
});

W najprostszej wersji obietnice są podobne do detektorów zdarzeń z wyjątkiem tych kwestii:

  • Obietnica może się udać lub nie powieść tylko raz. Nie może osiągnąć sukcesu ani dwa razy zawiedzić. Nie może też zastępować sukcesem lub porażką i odwrotnie.
  • Jeśli obietnica się udała lub nie powiodła się, a później dodasz wywołanie zwrotne o sukcesie lub niepowodzeniu, wywoływane zostanie właściwe wywołanie zwrotne, mimo że zdarzenie miało miejsce wcześniej.

Jest to bardzo przydatne w przypadku powodzenia lub niepowodzenia asynchronicznego, ponieważ mniej zależy Ci na dokładnym czasie udostępnienia danego elementu i zainteresowaniu reagowaniem na wyniki.

Terminologia z obietnicą

Domenic Denicola, dowód słuszności, przeczytał pierwszą wersję roboczą tego artykułu i ocenił mnie jako terminologię „F”. 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:

Obietnica może być:

  • fulfill – działanie związane z obietnicą zostało zrealizowane.
  • rejected – działanie powiązane z obietnicą nie powiodło się
  • pending (oczekuje) – nie został jeszcze zrealizowany ani odrzucony.
  • settled (rozstrzygnięto) – został zrealizowany lub odrzucony.

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.

W języku JavaScript pojawiają się obietnice!

Od dawna krążą obietnice w postaci bibliotek, takich jak:

Powyższe możliwości oraz JavaScript obiecują wspólne, ustandaryzowane zachowanie zwane Promises/A+. Jeśli korzystasz z biblioteki jQuery, masz w niej coś podobnego, co nazywamy odroczonym. Pamiętaj jednak, że pliki odroczone nie są zgodne z obiektami Promise/A+, co sprawia, że są nieco inne i mniej przydatne. Biblioteka jQuery ma również typ obietnicowy, ale jest to tylko podzbiory zasobów typu „Odroczone” i te same problemy.

Implementacje obiecujące działają ustandaryzowane, ale ich ogólne interfejsy API różnią się. 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 obietnic przyjmuje 1 argument (wywołanie zwrotne z 2 parametrami) oraz rozwiązanie i odrzucanie. 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 throw w starym języku JavaScript, odrzucanie z obiektem błędu jest zwyczajowe, ale niewymagane. Zaletą obiektów błędów jest przechwytywanie zrzutu stosu, dzięki czemu narzędzia do debugowania stają się bardziej przydatne.

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

Obietnice JavaScript zaczynały się w interfejsie DOM jako „Futures”, a następnie zmieniły nazwę na „Promises”, a później przenieśliśmy je do JavaScriptu. Warto używać JavaScriptu, a nie DOM, ponieważ będą dostępne w kontekstach JS innych niż przeglądarki, np. Node.js (to kolejne pytanie, czy używają ich w swoich podstawowych interfejsach API).

Chociaż są to funkcje JavaScript, DOM nie boi się ich używać. Wszystkie nowe interfejsy DOM API z asynchronicznymi metodami powodzenie/niepowodzenia będą używać obietnic. Ma to miejsce już w przypadku zarządzania limitami, zdarzeń wczytywania czcionek, ServiceWorker, Web MIDI i strumieni.

Zgodność z innymi bibliotekami

JavaScript obiecuje, że interfejs API JavaScript będzie traktować wszystko z metodą then() jako obietnicę (lub thenable w westchnieniu w swoim obietnicy). Jeśli więc użyjesz biblioteki, która zwraca obietnicę Q, nie musisz nic robić.

Mimo że, jak wspomniałem/wspomniałam, funkcje Odroczone w 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). Ma metodę then(), więc Promise.resolve() może przekształcić ją w obietnicę JavaScriptu. Czasami jednak opóźnienia przekazują wiele argumentów do swoich wywołań zwrotnych, na przykład:

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

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

Z kolei JS obiecuje zignorowanie wszystkich elementów oprócz pierwszego:

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

Na szczęście właśnie tego oczekujesz – a przynajmniej taki dostęp daje Ci dostęp do tego, czego chcesz. Pamiętaj też, że biblioteki jQuery są niezgodne z konwencją przekazywania obiektów błędów do odrzuceń.

Łatwiejszy dostęp do złożonego kodu asynchronicznego

Dobrze, zakodujmy parę rzeczy. Chcemy:

  1. Uruchom wskaźnik postępu ładowania
  2. Pobierz plik JSON z artykułem, który zawiera tytuł i adresy URL każdego rozdziału
  3. Dodaj tytuł strony
  4. Pobierz każdy rozdział
  5. Dodaj opowiadanie do strony
  6. Zatrzymaj wskaźnik postępu

...oraz informować użytkownika, jeśli na drodze coś poszło nie tak. W tym momencie chcemy też zatrzymać wskaźnik ładowania, ponieważ w przeciwnym razie będzie się nadal kręcić, zakręcić głową i wtedy prześlizgnąć się z innym interfejsem.

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 zgodny wstecznie. XMLHttpRequest to najlepsza kandydatura, ale na razie napiszmy prostą funkcję wysyłającą żą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 na przykład:

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ńcuch

Element then() nie kończy się na tekście. Możesz połączyć komponenty then razem, aby przekształcić wartości lub uruchamiać po kolei 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
})

W praktycznym przykładzie wróćmy 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. Mogliśmy zmienić funkcję get, aby używała obiektu JSON responseType, ale możemy też rozwiązać ten problem na stronie obietnic:

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

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

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

Praktycznie można bardzo łatwo przygotować 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.

Dodawanie działań asynchronicznych do kolejki

Możesz też połączyć elementy then w łańcuch, aby uruchamiać w sekwencji działania asynchroniczne.

Zwracanie czegoś z wywołania zwrotnego then() jest prawdziwą magią. Jeśli zwracasz wartość, jest wywoływane z nią kolejne działanie funkcji then(). Jeśli jednak zwrócisz coś podobnego do obietnicy, następny element then() czeka na niego i jest wywoływany tylko wtedy, gdy obietnica zostanie uzgodniona (powodzenie/niepowodzenie). 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 żądanie asynchroniczne do story.json, które daje nam zestaw adresów URL, o które prosimy. Później odbieramy pierwszy z nich. To właśnie wtedy obiecują, że zaczną się wyróżniać na tle prostych wzorców wywołań zwrotnych.

Możesz nawet stworzyć sposób, w jaki skróty pozwolą Ci dotrzeć 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. Hurra!

Obsługa błędów

Jak widzieliśmy, w przypadku then() przyjmuje się 2 argumenty: jeden za sukces, drugi za niepowodzenie (lub zapewnienie i odrzucenie, z 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() to nic specjalnego, to po prostu cukier dla then(undefined, func), ale jest czytelniejsza. 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 subtelna, ale niezwykle przydatna. Odrzucenie obietnicy powoduje przejście do następnego elementu then() z wywołaniem zwrotnym odrzucenia (lub catch(), ponieważ jest odpowiednikiem). Przy użyciu funkcji then(func1, func2) wywoływana jest funkcja func1 lub func2, ale nigdy nie obie jednocześnie. Jednak w metodzie then(func1).catch(func2) oba są wywoływane w przypadku odrzucenia func1, 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 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):

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

Wyjątki i obietnice JavaScript

Odrzucenie następuje, gdy obietnica zostanie bezpośrednio odrzucona, ale także domyślnie, gdy 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 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 zgłoszonych 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żywać ładunku, 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 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. Jeśli któreś z poprzednich działań nie powiedzie się, do strony pojawi się komunikat o błędzie „Nie udało się wyświetlić rozdziału”.

Podobnie jak w przypadku metody try/catch w przypadku JavaScriptu błąd jest wychwytywany, a kolejny kod jest kontynuowany. Wskaźnik postępu jest więc zawsze ukryty. Powyżej znajdziesz nieblokującą asynchroniczną wersję:

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'

Być może warto wykonać to działanie (catch()) tylko do celów logowania, bez naprawiania błędu. Aby to zrobić, po prostu ponownie prześlij 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 go wszystkie. Zróbmy to.

Równoległość i sekwencjonowanie: jak najlepsze wykorzystanie obu tych elementów

Myślenie asynchroniczne nie jest łatwe. Jeśli nie możesz sobie poradzić, napisz kod tak, jakby był synchroniczny. W takim 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 zostaje zsynchronizowana 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';
})

Jak jednak zapętlić adresy URL rozdziałów i pobrać je po kolei? 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 aplikacji asynchronicznej, więc rozdziały będą pojawiać się w kolejności pobierania, co w zasadzie wynika z Pulpfiction. To nie jest Pulpfiction, więc poprawmy to.

Tworzenie sekwencji

Chcemy przekształcić tablicę chapterUrls w sekwencję obietnic. W tym celu użyj narzędzia 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 wystąpienie zdarzenia Promise, zwróci ono je (uwaga: jest to zmiana specyfikacji, której jeszcze nie obsługują niektóre implementacje). Jeśli przekażesz je w sposób przypominający obietnicę (z metodą then()), tworzony jest autentyczny element Promise, który spełnia lub odrzuca w ten sam sposób. Jeśli przekażesz jakąkolwiek inną wartość, np. Promise.resolve('Hello'), przynosi oczekiwaną wartość. Jeśli nazwiesz ją bez wartości, tak jak wyżej, zostanie wypełnione wartością „niezdefiniowane”.

Występuje też Promise.reject(val), który tworzy obietnicę odrzucającą określoną przez Ciebie wartość (lub niezdefiniowaną).

Powyższy kod możemy posprzątać za pomocą polecenia 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())

Możesz zrobić to tak samo jak w poprzednim przykładzie, ale nie potrzebujesz osobnej zmiennej „sequence”. Nasze wywołanie zwrotne o skróceniu jest wywoływane dla każdego elementu w tablicy. „sekwencja” ma wartość Promise.resolve() za pierwszym razem, ale w przypadku pozostałych wywołań „sekwencja” to wartość, którą zwróciliśmy z poprzedniego wywołania. array.reduce jest bardzo przydatny przy kumulowaniu tablicy do jednej wartości – w tym przypadku jest to 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 o to chodzi – w pełni asynchroniczną wersję wersji synchronizacji. Ale możemy być lepsi. W tej chwili nasza strona jest pobierana w następujący sposób:

Przeglądarki potrafią pobierać wiele elementów jednocześnie, więc tracimy wydajność, pobierając rozdziały po kolei. Chcemy je wszystkie pobrać w tym samym czasie i przetworzyć, kiedy zostaną pobrane. Na szczęście istnieje interfejs API do tego:

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

Promise.all pobiera tablicę obietnic i tworzy obietnicę do realizacji, gdy wszystkie zostaną zrealizowane. Otrzymujesz tablicę wyników (niezależnie od tego, jakie obietnice zostały spełnione) 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 jednego po drugim i będzie krótsze niż przy pierwszej próbie. Rozdziały można pobierać w dowolnej kolejności, ale pojawiają się na ekranie we właściwej kolejności.

Nadal możemy jednak poprawić postrzeganą skuteczność. Gdy pojawia się rozdział pierwszy, powinniśmy dodać go do strony. Dzięki temu użytkownik będzie mógł zacząć czytać resztę rozdziałów, zanim pojawią się pozostałe. Gdy pojawia się rozdział 3, nie dodamy go do strony, bo użytkownik może nie zdawać sobie sprawy, że brakuje rozdziału 2. Kiedy pojawi się rozdział 2, możemy dodać rozdziały 2 i 3 itd.

Aby to zrobić, jednocześnie pobieramy wszystkie rozdziały w formacie JSON, a następnie tworzymy sekwencję, która dodaje 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 o to chodzi! Dostarczenie wszystkich treści zajmuje tyle samo czasu, ale pierwsza część treści jest dostępna wcześniej.

W tym trywialnym przykładzie wszystkie rozdziały pojawiają się mniej więcej w tym samym czasie, ale wyświetlanie pojedynczych rozdziałów będzie przesadzone.

Opisane powyżej wywołania zwrotne lub zdarzenia w stylu Node.js pozwalają podwoić zawartość kodu, ale, co ważniejsze, nie są aż tak proste. Nie jest to jednak koniec obietnic. W połączeniu z innymi funkcjami ES6 staje się jeszcze łatwiejszy.

Runda dodatkowa: większe możliwości

Od czasu napisania tego artykułu znacznie rozszerzyły się możliwości korzystania z niego. Od wersji Chrome 55 funkcje asynchroniczne umożliwiały pisanie kodu opartego na obietnicach w taki sposób, jakby był synchroniczny, ale bez blokowania wątku głównego. Więcej informacji znajdziesz w my async functions article. Najpopularniejsze przeglądarki obsługują zarówno funkcje obiecujące, jak i asynchroniczne. Szczegółowe informacje znajdziesz w dokumentacji Promise i funkcji asynchronicznej w MDN.

Podziękowania dla Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthura Evansa i Yutaky Hirano, którzy je sprawdzili i poprawili.

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