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 fetcha (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!