async/await w ES3
Sensowne narzędzia do obsługi asynchroniczności pojawiły się w JavaScripcie stosunkowo późno, bo dopiero w ES6. W jednej z późniejszych wersji pojawiła się też składnia async
/await
. Niemniej przy odrobinie wyobraźni można namiastkę tej składni stworzyć w starym, dobrym ES3.
Asynchroniczna rewolucja
Główną zmianą dotyczącą operacji asynchronicznych w ES6 było wprowadzenie do języka Promise
. Pozwalały one na odrzucenie starych callbacków na rzecz czegoś bardziej przyjaznego:
// callbacki
doAjax( function( response ) {
parseResponse( response, function( parsed ) {
displayResponse( parsed );
} )
} );
// Promise
doAjax().then( parseResponse ).then( displayResponse );
Niemniej wciąż nie był to kod równie czytelny, co kod synchroniczny. Jednak szybko znaleziono na to radę: nagięto użycie generatorów. Zauważono bowiem, że użycie wewnątrz nich komendy yield
sprawia, że generator zostaje “zamrożony”, a wykonanie przenosi się do miejsca wywołania generatora. Możemy wykonać dowolne operacje, a następnie wrócić w to samo miejsce generatora i wykonywać kod dalej, jakby nic się nie stało:
const generator = myGenerator();
generator.next(); // start
console.log( 'przejmujemy z powrotem kontrolę' );
generator.next( 'również dowolna wartość' ); // stop
function* myGenerator() {
console.log( 'start' );
yield 'dowolna wartość';
console.log( 'stop' );
}
Powyższy kod wyświetli w konsoli następujące komunikaty:
start
,przejmujemy z powrotem kontrolę
,stop
.
Dzięki temu, że yield
mogło zwracać do głównego flow dowolną wartość, można było zwrócić Promise
, które wykonywało swoją operację poza generatorem, a następnie wynik tej operacji był przekazywany z powrotem do generatora przy pomocy metody next
. Swego czasu dokładniej opisywałem ten mechanizm.
Jeśli komuś przywodzi to coś na myśl, to tak, w bardzo podobny sposób działa nowsza składnia async
/await
:
run();
async function run() {
const response = await doAjax();
const parsed = await parseResponse( response );
displayResponse( parsed );
}
Tym sposobem dotarliśmy do kodu, który jest asynchroniczny, ale – nie licząc słówka await
– wygląda tak samo jak kod synchroniczny.
Przekazywanie sterowania
Zarówno generator, jak i składnia async
/await
, opierają się na mechanizmie, który można nazwać “przekazywaniem sterowania”. W generatorze jest to o wiele bardziej widoczne, ponieważ nowa składnia ukrywa wszelkie szczegóły implementacyjne przed nami. Kiedy generator używa yield
, by zwrócić jakąś wartość, przekazuje tym samym sterowanie do miejsca, w którym wywołaliśmy generator. I to od tego miejsca zależy, czy i kiedy sterowanie powróci z powrotem do wnętrza generatora. Momentem powrotu do generatora może być właśnie wykonanie jakiejś operacji asynchronicznej.
Niemniej, jeśli przyjrzymy się dokładniej JavaScriptowi, odkryjemy, że istnieje w nim przynajmniej jeszcze jeden mechanizm pozwalający przekazać sterowanie w inne miejsce kodu: try
/catch
.
try {
console.log( 'start' );
throw 'dowolna wartość';
console.log( 'stop' );
} catch( e ) {
console.log( 'przejmujemy sterowanie' );
}
Warto zauważyć, że throw
działa z dowolną wartością, nie tylko obiektem Error
. To sprawia, że można rzucić np. Promise
.
Powyższy kod wyświetli w konsoli:
start
,przejmujemy sterowanie
.
Jak widać zatem, throw
przerzuciło nas do bloku catch
. Niemniej, w przeciwieństwie do wcześniej omawianych mechanizmów, nie istnieje sposób na powrót do wnętrza bloku try
. Trzeba zatem obejść ten problem!
Kalekie async
/await
w ES3
Załóżmy, że nasz projekt używa biblioteki do Promise
, która działa tak samo jak te natywne. W chwili, gdy operacja asynchroniczna wewnątrz Promise
się skończy, chcielibyśmy wrócić do naszego kodu. Niemniej musimy jakoś poinformować catch
, gdzie dokładnie ma wrócić. W przypadku generatorów i async
/await
odpowiedzialne za to były odpowiednie słowa kluczowe (yield
i await
). W naszym przypadku słów kluczowych nie możemy użyć. Ale możemy spróbować zastąpić je funkcją!
var asyncCallback;
try {
console.log( 'start' );
resume( function() { // 4
console.log( 'stop' );
} );
throw asyncOperation(); // 1
} catch( e ) {
if ( !( e instanceof Promise ) ) {
throw e;
}
console.log( 'przejmujemy sterowanie' );
e.then( function() { // 2
asyncCallback(); // 3
asyncCallback = '';
} );
}
function resume( fn ) {
asyncCallback = fn; // 5
}
function asyncOperation() {
return new Promise( function( resolve ) {
setTimeout( resolve, 1000 );
} );
}
Powyższy kod wyświetli nam w konsoli:
start
,przejmujemy sterowanie
,stop
.
Niemniej co się dokładnie dzieje? Wewnątrz bloku try
rzucamy operacją asynchroniczną (1). Blok catch
następnie czeka, aż zwrócony Promise
się rozwiąże (2) i wywołuje callback zapisany w zmiennej asyncCallback
(3). To, jaka funkcja dokładnie ma zostać wywołana, jest ustalane przez funkcję resume
(4), która do zmiennej asyncCallback
przekazuje swój argument (5). Funkcja resume
w powyższym kodzie jest odpowiednikiem słowa kluczowe await
i określa miejsce, do którego ma powrócić sterowanie w bloku try
. Jedynym mankamentem jest fakt, że musi być wywołana przed throw
– inaczej nigdy nie powrócimy do bloku try
. Instrukcja throw
bowiem kategorycznie kończy wykonywanie kodu w danym bloku.
Prawdę mówiąc nie wiem, czy taki sposób obsługi asynchroniczności jest bardziej intuicyjny od tradycyjnych callbacków, czy nie. Na pewno jest niezwykle pomysłowy i pozwala ominąć problem tzw. callback hell. Natknąłem się na niego w starym, poczciwym YUI.Test.
I to by było na tyle. Miłej kontemplacji przyjemnej asynchroniczności w nowoczesnym JS-ie!
Komentarze
Przejdź do komentarzy bezpośrednio na Githubie.