I cięcie!
Ostatnio dziwnie popularny zrobił się temat anulowania pobierania danych przez fetch
. Wydaje mi się jednak, że umyka przy tym pewna istotna kwestia: to rozwiązanie powinno działać ze wszystkimi asynchronicznymi API.
Sygnał przerwania
Bardzo szybko po wprowadzeniu Promise
do ES2015 i wysypie pierwszych Web API wykorzystujących nowy sposób obsługi asynchroniczności pojawiła się też potrzeba przerywania asynchronicznych zadań (i to jeszcze przed oficjalnym ogłoszeniem samego ES2015!). W tym celu próbowano stworzyć uniwersalne rozwiązanie, które miało stać się częścią standardu ECMAScript. Dyskusje szybko jednak utknęły w martwym punkcie, a palący problem pozostał. Z tego też powodu WHATWG wzięło sprawy w swoje ręce i wprowadziło rozwiązanie na poziomie DOM-u – AbortController
.
Oczywistą wadą takiego obrotu sprawy jest fakt, że rozwiązanie to nie jest dostępne w Node.js. Tam wciąż nie ma sposobu na eleganckie przerywanie zadań asynchronicznych.
Jak można zauważyć w specyfikacji, rozwiązanie to jest opisane w sposób jak najbardziej ogólny. Sprawia to, że można je użyć do API, które powstaną w przyszłości. Na chwilę obecną obsługa wbudowana jest tylko w fetch
, ale nic nie stoi na przeszkodzie, by wykorzystać AbortController
w naszym własnym kodzie.
Zanim jednak do tego przejdziemy, przyjrzyjmy się chwilę, jak w ogóle AbortController
działa:
const abortController = new AbortController(); // 1
const abortSignal = abortController.signal; // 2
fetch( 'http://example.com', {
signal: abortSignal // 3
} ).catch( ( { message } ) => { // 5
console.log( message );
} );
abortController.abort(); // 4
Na samym początku tworzymy instancję interfejsu DOM-owego AbortController
(1) i wyciągamy z niej własność signal
(2). Następnie wywołujemy fetch
i przekazujemy własność signal
jako jedną z opcji fetch
a (3). Żeby przerwać ściąganie zasobu wystarczy teraz wywołać metodę abortController.abort
(4). To sprawi, że obiecanka stworzono przez fetch
zostanie automatycznie odrzucona i uruchomi się block catch
(5).
Sama własność signal
również jest ciekawa i to tak naprawdę ona jest głównym aktorem tego dramatu. Jest to instancja interfejsu DOM-owego AbortSignal
. Posiada ona własność aborted
, przechowującą informację, czy użytkownik już wywołał metodę abortController.abort
, a dodatkowo można jej przypiąć listenera do zdarzenia abort
, które zachodzi, gdy metoda abortController.abort
jest wywoływana. Innymi słowy: AbortController
jest tak naprawdę publicznym interfejsem dla AbortSignal
.
Przerywalna funkcja
Wyobraźmy sobie, że tworzymy asynchroniczną funkcję, która wykonuje naprawdę skomplikowane obliczenia (np. przetwarza sporą tablicę w partiach). Dla uproszczenia w przykładzie funkcja będzie symulować ciężką pracę po prostu czekając 5 sekund przed zwróceniem wyniku:
function calculate() {
return new Promise( ( resolve, reject ) => {
setTimeout( ()=> {
resolve( 1 );
}, 5000 );
} );
}
calculate().then( ( result ) => {
console.log( result );
} );
Jednak czasami użytkownik może chcieć przerwać taką kosztowną operację. Wypada zatem mu to umożliwić! Dodajmy zatem przycisk, który będzie rozpoczynał i anulował obliczanie
<button id="calculate">Calculate</button>
<script type="module">
document.querySelector( '#calculate' ).addEventListener( 'click', async ( { target } ) => { // 1
target.innerText = 'Stop calculation';
const result = await calculate(); // 2
alert( result ); // 3
target.innerText = 'Calculate';
} );
function calculate() {
return new Promise( ( resolve, reject ) => {
setTimeout( ()=> {
resolve( 1 );
}, 5000 );
} );
}
</script>
W powyższym kodzie przypinamy do przycisku asynchroniczny listener dla zdarzenia click
, a następnie wywołujemy naszą funkcję do obliczeń (2). Po pięciu sekundach pojawi się okienko z wynikiem (3).
script[type=module]
powoduje, że cały kod JS zawarty w tym elemencie jest uruchamiany w strict mode. Osobiście uważam to za bardziej eleganckie rozwiązanie niż 'use strict';
.
Dodajmy zatem obsługę dla przerywania asynchronicznego zadania:
{ // 1
let abortController = null; // 2
document.querySelector( '#calculate' ).addEventListener( 'click', async ( { target } ) => {
if ( abortController ) {
abortController.abort(); // 5
abortController = null;
target.innerText = 'Calculate';
return;
}
abortController = new AbortController(); // 3
target.innerText = 'Stop calculation';
try {
const result = await calculate( abortController.signal ); // 4
alert( result );
} catch {
alert( 'WHY DID YOU DO THAT?!!' ); // 9
} finally { // 10
abortController = null;
target.innerText = 'Calculate';
}
} );
function calculate( abortSignal ) {
return new Promise( ( resolve, reject ) => {
const timeout = setTimeout( ()=> {
resolve( 1 );
}, 5000 );
abortSignal.addEventListener( 'abort', () => { // 6
const error = new DOMException( 'Calculation aborted by user', 'AbortError' );
clearTimeout( timeout ); // 7
reject( error ); // 8
} );
} );
}
}
Kod się dość rozrósł. Ale spokojnie, nie zrobił się specjalnie trudniejszy! Całość została zamknięta w bloku (1), co – przy wykorzystaniu zmiennych o zasięgu blokowym – jest odpowiednikiem IIFE. Dzięki temu nasza zmienna abortController
(2) nie wypłynie do globalnego scope. Na chwilę obecną ta zmienna ma wartość null
. Zmienia się to w momencie kliknięcia na przycisk. Przypisujemy jej wtedy jako wartość nową instancję AbortController
(3). Następnie przekazujemy jej własność signal
bezpośrednio do naszej funkcji calculate
(4). Teraz, gdy użytkownik kliknie w przycisk przed upływem pięciu sekund, kliknięcie spowoduje wywołanie abortController.abort
(5). To z kolei wywoła zdarzenie abort
na abortController.signal
, którego nasłuchujemy wewnątrz calculate
(6). Wówczas usuwamy tykający timer (7) i odrzucamy obiecankę z odpowiednim błędem (8). Typ tego błędu ten wynika bezpośrednio ze specyfikacji DOM. Błąd ten z kolei powoduje wykonanie się czynności z bloków catch
(9) i finally
(10).
Więcej o nowej składni bloków try
/catch
przeczytać można na MDN.
Wypada się także przygotować na sytuację taką, jak poniższa:
const abortController = new AbortController();
abortController.abort();
calculate( abortController.signal );
W tym wypadku zdarzenie abort
nie zajdzie, bo zaszło przed przekazaniem sygnału do funkcji calculate
. Dlatego wypada ją nieco przebudować:
function calculate( abortSignal ) {
return new Promise( ( resolve, reject ) => {
const error = new DOMException( 'Calculation aborted by user', 'AbortError' ); // 1
if ( abortSignal.aborted ) { // 2
return reject( error );
}
const timeout = setTimeout( ()=> {
resolve( 1 );
}, 5000 );
abortSignal.addEventListener( 'abort', () => {
clearTimeout( timeout );
reject( error );
} );
} );
}
Błąd został przeniesiony na samą górę (1), tak, aby można go było użyć w dwóch różnych miejscach kodu (chociaż bardziej eleganckim rozwiązaniem byłaby pewnie fabryka błędów – jakkolwiek to dziwnie nie brzmi). Dodatkowo pojawiła się tzw. klauzula strażnicza, sprawdzająca wartość abortSignal.aborted
(2). Jeśli jest ona równa true
, wówczas calculate
odrzuca obiecankę z odpowiednim błędem, bez wykonywania żadnych innych czynności.
I tym sposobem stworzyliśmy w pełni przerywalną funkcję asynchroniczną! Demo jest dostępne online. Miłego przerywania!