Obietnice kodu JavaScript: wprowadzenie

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

Jake Archibald
Jake Archibald

Przygotuj się na kluczowy moment w historii tworzenia stron internetowych.

[Zaczyna się werbel]

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

[Wybuchające fajerwerki, błyszczący deszcz z góry, tłum oszałamiający]

W tym momencie zaliczasz się do jednej z tych kategorii:

  • Ludzie dopingują Cię, ale nie masz pewności, o co chodzi. Może nie do końca wiesz, co to jest „obietnica”. Wzruszasz ramionami, ale ciężar błyszczącego papieru dźże się na ramionach. Jeśli tak, to nie martw się. Zajęło mi sporo czasu, by zrozumieć, dlaczego mogę się tym zająć. Najlepiej zacząć od początku.
  • Wymiatasz! O czasie, tak? Już korzystałeś z tych funkcji, ale denerwuje Cię, że wszystkie implementacje mają nieco inny interfejs API. Jaki jest interfejs API dla oficjalnej wersji JavaScript? Prawdopodobnie zaczniesz od terminologii.
  • Ty o tym już wiesz i żartujesz z tych, którzy skaczą i czują, że to dla nich dobra wiadomość. Poświęć chwilę, aby ożywić się swoją wyższością, a potem przejdź od razu do dokumentacji API.

Obsługa przeglądarek i kod polyfill

Obsługa przeglądarek

  • 32
  • 12
  • 29
  • 8

Źródło

Jeśli chcesz, by przeglądarki, które nie spełniają wymagań, w pełni spełniają wymogi specyfikacji, lub dodasz obietnice do innych przeglądarek i środowiska Node.js, wypróbuj program polyfill (2K gzip).

O co tu chodzi?

JavaScript jest jednowątkowy, co oznacza, że 2 bity skryptu nie mogą działać jednocześnie. Muszą działać jeden po drugim. W przeglądarkach JavaScript udostępnia wątek razem z innymi elementami, które różnią się w zależności od przeglądarki. Zazwyczaj JavaScript znajduje się w tej samej kolejce co malowanie, aktualizowanie stylów i obsługa działań użytkownika (takich jak wyróżnianie tekstu czy interakcję z elementami sterującymi formularza). Aktywność w jednej z tych rzeczy powoduje opóźnienie innych.

Każdy człowiek jest wielowątkowy. Możesz pisać kilkoma palcami, możesz też jednocześnie prowadzić i przytrzymywać rozmowę. Jedyną funkcją blokującą, z którą mamy do czynienia, jest kichanie, w którym na czas kichania należy zawiesić całą aktywność. To dość irytujące, zwłaszcza gdy jedziesz samochodem i próbujesz pogadać. Nie chcesz pisać kodu, który spłyci się w mgnieniu oka.

Prawdopodobnie używałeś(-aś) zdarzeń i wywołań zwrotnych, aby obejść ten problem. Oto wydarzenia:

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

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

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

Wcale nie kichamy. Pobieramy obraz, dodajemy kilka detektorów i JavaScript może przestać działać do czasu wywołania jednego z tych detektorów.

W powyższym przykładzie możliwe jest, ż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 wychwytuje to obrazów, które zostały błędnie rozpoznane, kiedy nie udało nam się ich przesłuchać. Niestety DOM nie daje na to możliwości. Wczytuje się też 1 obraz. Sprawy się komplikują, gdy chcemy wiedzieć, kiedy został wczytany zestaw obrazów.

Wydarzenia nie zawsze są najlepszym sposobem

Zdarzenia świetnie się sprawdzają w przypadku zdarzeń, które mogą wystąpić wielokrotnie w przypadku tego samego obiektu – keyup, touchstart itp. W przypadku tych zdarzeń nie zależy Ci na tym, co wydarzyło się przed dołączeniem detektora. Jeśli jednak chodzi o asynchroniczne sukcesy i porażki, najlepiej byłoby, gdyby:

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 lepszymi nazwami. Jeśli elementy graficzne HTML miały metodę „ready”, która zwróciła obietnicę, można by to zrobić:

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

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

Najogólniej obiecujące słowa działają podobnie do detektorów zdarzeń, z wyjątkiem tych sytuacji:

  • Obietnica może odnieść sukces lub niepowodzenie tylko raz. Nie można odnieść sukcesu ani dwa razy ponieść porażkę. Nie można też przejść z powodu na porażkę ani na odwrót.
  • Jeśli obietnica została zrealizowana lub nie powiodła się, a później dodasz wywołanie zwrotne o udanym lub nieudanym wywołaniu, zostanie uruchomione poprawne wywołanie zwrotne, mimo że zdarzenie miało miejsce wcześniej.

Jest to bardzo przydatne w przypadku asynchronicznego sukcesu lub porażki, ponieważ mniej interesuje Cię dokładny moment udostępnienia czegoś, a bardziej interesuje Cię reagowanie na efekt.

Terminologia związana z obietnicą

Domenic Denicola przeczytał pierwszą wersję roboczą tego artykułu i przyznał mi ocenę „F” za terminologię. Umieścił mnie w areszcie, zmusił mnie do skopiowania rządów stanów i losów i napisał zmartwiony list do moich rodziców. Mimo to często zdarza mi się pomieszać terminologię, ale oto podstawowe informacje:

Obietnicą może być:

  • fulfilled – działanie związane z obietnicą zostało wykonane.
  • rejected – działanie związane z obietnicą nie powiodło się.
  • pending (oczekuje) – nie został jeszcze zrealizowany ani odrzucony
  • settled (rozliczone) – zrealizowane lub odrzucone;

W specyfikacji używamy też terminu thenable do opisania obiektu, który jest podobny do obietnicy, ponieważ zawiera metodę then. Ten termin przypomina mi o byłym trenerze futbolu amerykańskiego Terry'ego Venablesa, więc będę go używać jak najmniej.

Obietnice pojawiają się w JavaScript!

Obietnice są znane już od jakiegoś czasu w postaci bibliotek, takich jak:

Powyższe i JavaScript jest wspólne, ustandaryzowane zachowanie nazywane Promises/A+. Jeśli korzystasz z biblioteki jQuery, uzyskasz w nich podobny efekt Odroczone. Jednak elementy odroczone nie są zgodne z promisją/A+, co sprawia, że są subtelnie różne i mniej przydatne, więc zachowaj ostrożność. Biblioteka jQuery ma również typ obietnicy, ale jest to tylko podzbiór Odroczonych i zawiera te same problemy.

Chociaż implementacje z obietnicami są zgodne z ustandaryzowanym zachowaniem, ich ogólne interfejsy API się różnią. W interfejsie API obietnice dotyczące kodu JavaScript są podobne do Reply.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 funkcji obiecujących przyjmuje 1 argument – wywołanie zwrotne z 2 parametrami, rozpoznaje i odrzuca. Wykonaj jakąś czynność w wywołaniu zwrotnym (na przykład asynchroniczne), a następnie użyj instrukcji „resolve”, jeśli wszystko zadziałało. W przeciwnym razie wywołaj odrzucenie.

Podobnie jak w przypadku throw w starym skrypcie JavaScript, odrzucanie za pomocą obiektu Error jest standardem, ale nie jest wymagane. Obiekty błędu rejestrują zrzut stosu, dzięki czemu narzędzia do debugowania są bardziej przydatne.

Aby z niej skorzystać:

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

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

Obietnice JavaScriptu zaczynały się w DOM od „Futures”, a następnie przemianowane na „Promises”; a na końcu przeniosły się do JavaScriptu. Używanie ich w językach JavaScript zamiast w DOM jest świetne, ponieważ będą dostępne w kontekstach JS innych niż przeglądarki, np. Node.js (to kolejne pytanie to, czy będą ich używać w swoich podstawowych interfejsach API).

Chociaż to funkcja JavaScript, DOM nie boi się ich używać. Wszystkie nowe interfejsy DOM API z asynchronicznymi metodami powodzenia/niepowodzeń będą korzystać z deklaracji. Dotyczy to m.in. zarządzania limitami, zdarzeń wczytywania czcionki, ServiceWorker, Web MIDI czy strumieni.

Zgodność z innymi bibliotekami

Interfejs API JavaScript obiecuje traktowanie wszystkiego z metodą then() jako obiecującą (lub thenable w funkcji obiecujące westchnienie), więc jeśli używasz biblioteki, która zwraca obietnicę Q, będzie to dobrze współpracować z nowymi obietnicami JavaScript.

Chociaż, jak wspomniałem, opóźnienia w jQuery są trochę... nieprzydatne. Na szczęście możesz przekazywać im standardowe obietnice – warto to zrobić jak najszybciej:

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

W tym przypadku $.ajax jQuery zwraca wartość Odroczoną. Ponieważ zawiera metodę then(), Promise.resolve() może przekształcić ją w obietnicy JavaScriptu. Czasami jednak opóźnienia przekazują do wywołań zwrotnych wiele argumentów, na przykład:

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

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

Z kolei obiecujące JS ignorują wszystkie elementy oprócz pierwszej:

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

Na szczęście zwykle tego właśnie oczekujesz, a przynajmniej dasz Ci dostęp do tego, co chcesz. Pamiętaj też, że jQuery nie jest zgodne z konwencją przekazywania obiektów Error do odrzuceń.

Złożony kod asynchroniczny jest teraz prostszy

Napiszmy kilka rzeczy. Powiedzmy, że chcemy:

  1. Uruchom wskaźnik postępu wczytywania
  2. Pobierz plik JSON historii – tytuł i adres URL każdego rozdziału
  3. Dodaj tytuł strony
  4. Pobierz każdy rozdział
  5. Dodaj artykuł do strony
  6. Zatrzymaj wskaźnik postępu

...a także informowania użytkownika, czy coś pójdzie nie tak. Musimy też zatrzymać wskaźnik postępu, bo w przeciwnym razie będzie się obracać, będzie mieć zawroty głowy i wpadnie na jakiś inny interfejs.

Oczywiście do wyświetlenia historii nie używa się JavaScriptu, ponieważ wyświetlanie w postaci HTML przebiega szybciej, ale w przypadku interfejsów API ten wzorzec jest dość powszechny: pobiera się wiele danych, a gdy wszystko jest gotowe, wykonuje jakieś działanie.

Zacznijmy od pobierania danych z sieci:

Obiecujące XMLHttpRequest

Jeśli to możliwe, stare interfejsy API zostaną zaktualizowane, tak aby wykorzystywały obietnice w sposób zgodny wstecznie. XMLHttpRequest jest doskonałą kandydatką, 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 go użyjemy:

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 tekstuXMLHttpRequest, co świetnie. Im mniej informacji patrzę na wielbłąda XMLHttpRequest, tym szczęśliwsze będzie moje życie.

Łańcuchy

Element then() to nie koniec historii. Możesz połączyć łańcuchy elementów then, aby przekształcić wartości lub wykonywać kolejno dodatkowe działania asynchroniczne.

Przekształcanie wartości

Aby przekształcić wartości, możesz po prostu zwrócić 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
})

Praktyczny przykład wróćmy do:

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

Odpowiedź jest w formacie JSON, ale obecnie odbieramy ją jako zwykły tekst. Mogliśmy zmienić naszą funkcję get, aby używała kodu JSON responseType, ale możemy też rozwiązać ten problem w obietnicach:

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 utworzyć skrót:

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

Funkcja getJSON() jest bardzo prosta:

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

getJSON() nadal zwraca obietnicę – taką, która pobiera adres URL, a następnie analizuje odpowiedź w formacie JSON.

Umieszczenie działań asynchronicznych w kolejce

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

Gdy zwracasz coś z wywołania zwrotnego then(), robisz to magicznie. Jeśli zwrócisz wartość, następny element then() zostanie wywołany z tą wartością. Jeśli jednak zwrócisz coś, co obiecuje klientom, kolejny then() czeka na niego i jest wywoływany tylko wtedy, gdy ta obietnica się ustabilizuje (udane/nieudane). 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 tu żądanie asynchroniczne do usługi story.json, podając zestaw adresów URL, których możemy zażądać. Następnie żądamy pierwszego z nich. W rezultacie obietnice zaczynają się wyróżniać na tle prostych wzorców wywołań zwrotnych.

Możesz nawet utworzyć skrót, który pozwoli Ci otwierać rozdziały:

var storyPromise;

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

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

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

story.json pobieramy dopiero po wywołaniu funkcji getChapter, ale następnym razem, gdy nazwa getChapter się nazywa, wykorzystamy obietnicę historii, więc dane story.json są pobierane tylko raz. Hip hip hurra!

Obsługa błędów

Jak już wspomnieliśmy, funkcja then() przyjmuje 2 argumenty: jeden na sukces, drugi na porażkę (lub na zrealizowanie i odrzucenie)

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

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

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

W aplikacji catch() nie ma nic specjalnego – then(undefined, func) zawiera tylko cukier, ale jest bardziej czytelny. Zwróć uwagę, że 2 przykłady kodu powyżej nie działają tak samo. 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. Obietnice odrzucenia pomijają następny element then() z wywołaniem zwrotnym odrzucającym (lub catch(), ponieważ jest odpowiednikiem). W przypadku then(func1, func2) wywoływane jest wywołanie func1 lub func2, ale nie oba jednocześnie. Jednak w przypadku metody then(func1).catch(func2) obie te metody są wywoływane, jeśli metoda func1 odrzuci, ponieważ są one oddzielnymi krokami 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 jest bardzo podobny do zwykłego kodu JavaScript typu try/catch. Błędy występujące w metodzie „try” trafiają od razu do bloku catch(). Oto powyższy schemat blokowy (ponieważ uwielbiam schematy blokowe):

Postępuj zgodnie z niebieskimi liniami oznaczającymi spełnienie obietnic, a czerwonymi – o tych, które odrzucają.

Wyjątki i obietnice dotyczące języka JavaScript

Odrzucenie następuje, gdy obietnica jest jawnie odrzucana, ale także pośrednio, jeśli w wywołaniu zwrotnym konstruktora występuje 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 obietnicami warto wykonywać w wywołaniu zwrotnym konstruktora obiecujących obietnic. Dzięki temu błędy są automatycznie wykrywane i odrzucane.

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

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

Obsługa błędów w praktyce

W przypadku naszej historii i rozdziałów możemy użyć tagu 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 nie uda się pobrać funkcji story.chapterUrls[0] (np. http 500 lub użytkownik jest offline), zostaną pominięte wszystkie kolejne wywołania zwrotne, w tym wywołanie zwrotne w tabeli getJSON(), które próbuje przeanalizować odpowiedź jako JSON, i pominięcie wywołania zwrotnego, które dodaje do strony element chapter1.html. Zamiast tego przechodzi do wywołania zwrotnego typu catch-back. Oznacza to, że jeśli któreś z poprzednich działań zakończyły się niepowodzeniem, do strony pojawi się komunikat „Nie udało się wyświetlić rozdziału”.

Podobnie jak w przypadku próby użycia JavaScriptu, błąd jest wychwytywany, a kolejny kod jest kontynuowany, więc wskaźnik postępu jest zawsze ukryty. Powyższa wersja staje się 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'

Możesz 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 naszej 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 je wszystkie oglądać. Zróbmy to.

Równoległość i sekwencjonowanie: wykorzystanie potencjału obu tych funkcji

Myślenie asynchroniczne nie jest łatwe. Jeśli nie wiesz, co zrobić, spróbuj napisać 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'

Zgadza się. Jest ono jednak synchronizowane i blokuje przeglądarkę na czas pobierania plików. Aby to działało asynchronicznie, wykorzystujemy then() do koordynowania działań między sobą.

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 przejrzeć 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 jest asynchronicznie, więc rozdziały pojawiają się w takiej kolejności, w jakiej je pobierają. Tak właśnie powstała Pulp Fiction. To nie jest pulp fiction, 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 widzieliśmy Promise.resolve() i tworzy on obietnicę zgodną z obietnicą. Jeśli przekażesz jej instancję kodu Promise, po prostu ją zwróci (uwaga: jest to zmiana w specyfikacji, z których jeszcze nie korzystają niektóre implementacje). Jeśli przekażesz mu obiekt obiecujący (z metodą then()), zostanie utworzony autentyczny Promise, który będzie wypełniany/odrzucany w ten sam sposób. Jeśli prześlesz inną wartość, np. Promise.resolve('Hello'), tworzy obietnicę spełniającą tę wartość. Jeśli podasz ją bez wartości, tak jak wyżej, zostaną wypełnione wartością „undefined” (nieokreślona).

Dostępna jest też funkcja Promise.reject(val), która tworzy obietnicę, która jest odrzucana z podaną przez Ciebie wartością (lub niezdefiniowaną).

Możemy uporządkować powyższy kod za pomocą array.reduce:

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

Działa to tak samo jak w poprzednim przykładzie, ale nie wymaga osobnej zmiennej „sequence”. Zmniejszenie wywołania zwrotnego jest wywoływane dla każdego elementu w tablicy. „sekwencja” za pierwszym razem to Promise.resolve(), ale dla pozostałych wywołań „sekwencja” to wartość zwrócona z poprzedniego wywołania. array.reduce bardzo przydaje się do sprowadzania tablicy do jednej wartości, co w tym przypadku jest obietnicą.

Podsumujmy to wszystko:

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

Tutaj mamy w pełni asynchroniczną wersję synchronizacji. Możemy robić to lepiej. Aktualnie pobieramy naszą stronę w następujący sposób:

Przeglądarki dobrze sobie radzą z pobieraniem wielu elementów naraz, więc pobieranie rozdziałów jeden po drugim obniża wydajność. Musimy je pobrać w tym samym czasie i przetworzyć, gdy już dotrą. Na szczęście istnieje interfejs API, który służy do tego:

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

Promise.all pobiera tablicę obietnic i tworzy obietnicę, która się realizuje, gdy wszystkie się wyświetlą. Otrzymasz tablicę wyników (w której spełniły się obietnice) w tej samej kolejności, w jakiej zostały przekazane.

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 pojedynczo i zawierać mniej kodu 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.

Możemy jednak poprawić postrzeganą skuteczność. Gdy pojawi się pierwszy rozdział, powinniśmy dodać go do strony. Dzięki temu użytkownik będzie mógł zacząć czytać, zanim dotrze reszta rozdziałów. Gdy pojawi się rozdział 3, nie dodamy go na stronie, ponieważ użytkownik może nie zdawać sobie sprawy, że brakuje rozdziału 2. Gdy pojawi się rozdział drugi, możemy też dodać rozdziały 2, 3 itd.

Aby to zrobić, pobieramy jednocześnie wszystkie rozdziały w pliku 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';
})

Gotowe. Najlepsze w obu przypadkach! Dostarczenie całej zawartości zajmuje tyle samo czasu, ale pierwszą część treści użytkownik dostaje szybciej.

W tym trywialnym przykładzie wszystkie rozdziały pojawiają się mniej więcej w tym samym czasie, ale korzyści wynikające z wyświetlania pojedynczych rozdziałów będą wyolbrzymiane w miarę obszerniejszych rozdziałów.

Opisane powyżej czynności z użyciem wywołań zwrotnych lub zdarzeń w stylu Node.js pozwalają uniknąć zduplikowania kodu, ale, co ważniejsze, nie są takie łatwe do śledzenia. To jednak nie koniec obiecywania, które w połączeniu z innymi funkcjami ES6 stają się jeszcze łatwiejsze.

Runda dodatkowa: większe możliwości

Od czasu napisania tego artykułu znacznie zwiększył się możliwości korzystania z obietnic. Od wersji Chrome 55 funkcje asynchroniczne umożliwiają pisanie kodu opartego na obietnicach tak, jakby był synchroniczny, ale bez blokowania wątku głównego. Więcej informacji na ten temat znajdziesz w my async functions article. Główne przeglądarki obsługują zarówno funkcje obiecujące, 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, którzy sprawdzili te teksty i wprowadzili poprawki oraz rekomendacje.

Dziękuję też Mathiasowi Bynensowi za zaktualizowanie różnych fragmentów artykułu.