Tik-tak
W przeglądarce dostępnych jest dużo różnych API. Niektóre z nich nieustannie ewoluują czy wręcz zostają zastąpione przez nowsze, lepiej zaprojektowane API. Inne z kolei wydają się całkiem zapomniane – jak choćby czasomierze (timers). Ostatnio używałem ich w małym projekcie i naszła mnie garść refleksji, którymi postanowiłem się podzielić ze światem.
Czym są czasomierze?
Czasomierze (liczniki czasu, timery) to przeglądarkowe API, które służy do odliczania czasu. Jest dokładnie opisane w specyfikacji HTML i składa się z czterech globalnych funkcji:
setTimeout()
– służącej do jednorazowego odliczenia czasu i wykonania po tym czasie jakiejś czynności,setInterval()
– służącej do wykonywania jakiejś czynności co określony czas,clearTimeout()
– służącej do anulowania odliczania czasu rozpoczętego przy pomocysetTimeout()
,clearInterval()
– służącej do anulowania odliczania czasu rozpoczętego przy pomocysetInterval()
.
Przy ustawianiu czasomierza należy podać czynność, którą chcemy wykonać, oraz czas, po jakim ma zostać wykonana (w przypadku setTimeout()
), lub odstęp między poszczególnymi wykonaniami czynności (w przypadku setInterval()
):
setTimeout( () => {
console.log( 'timeout' ); // 2
}, 5000 ); // 1
setInterval( () => {
console.log( 'tik' ); // 4
}, 1000 ); // 3
W przypadku setTimeout()
chcemy, żeby po 5 sekundach (1) wyświetlił się w konsoli napis 'timeout'
(2). W przypadku setInterval()
z kolei chcemy, żeby co sekundę (3) wyświetlał się w konsoli napis 'tik'
(4). Warto przy tym zwrócić uwagę na dwie rzeczy. Pierwsza z nich to fakt, że czas należy podać w milisekundach. Druga to fakt, że nie ma gwarancji, że czasomierz dokładnie odmierzy podany czas. Przeglądarka zagwarantuje jedynie, że odmierzy co najmniej tyle, ile jej kazano – a więc co najmniej 5 sekund w przypadku setTimeout()
oraz co najmniej sekundę w przypadku setInterval()
. Zależy to od wielu czynników, m.in. od tego, czy przeglądarka ma akurat “wolne”, czy procesor nie jest zajęty przez coś innego, czy okno przeglądarki jest sfocusowane, itd.
Zarówno setTimeout()
, jak i setInterval()
zwracają id, które może być następnie użyte w clearTimeout()
i clearInterval()
:
const timeoutId = setTimeout( timeoutHandler, 5000 );
const intervalId = setInterval( intervalHandler, 1000 );
clearTimeout( timeoutId );
clearInterval( intervalId );
Żeby nie było za prosto, clearTimeout()
zadziała też dla czasomierza stworzonego przez setInterval()
, a clearInterval()
– dla czasomierza stworzonego przez setTimeout()
. Wynika to z tego, że standard przewiduje wspólną kolejkę dla obydwu typów czasomierzy.
Choć czasomierze są API przeglądarkowym, są na tyle użyteczne, że pojawiły się także w pozostałych środowiskach uruchomieniowych JS-a: Node, Bunie i Deno. Niemniej implementacje czasomierzy między przeglądarkami a resztą środowisk mogą się nieco różnić. Dlatego w tym artykule skupiam się głównie na przeglądarkach.
Istniejące problemy
Tak po prawdzie czasomierze nie zmieniły się od czasu swojego powstania. A to oznacza, że ominął je szereg rewolucji i ewolucji, jakie zaszły w JS-owym ekosystemie przez ostatnie, cóż, dekady. Osobiście widzę trzy główne problemy:
- przestarzałe podejście do asynchroniczności,
- brak współpracy z
AbortController
em, - nieprzyjazny format czasu.
Przyjrzyjmy się pokrótce każdemu z tych problemów.
W przykładach wykorzystuję setTimeout()
, ale wszystkie opisane problemy występują także w przypadku setInterval()
.
Przestarzałe podejście do asynchroniczności
ES2015 przyniosło prawdziwą rewolucję związana ze zmianą podejścia do asynchroniczności w JS-ie. Polegała ona na odejściu od funkcji zwrotnych (callbacks) na rzecz obietnic (Promise
). Zmianę tą bardzo ładnie widać na przykładzie API w Node, np. do odczytywania zawartości pliku. Starsza, callbackowa wersja (fs.readFile()
) doczekała się obietnicowego odpowiednika (fsPromises.readFile()
):
import { readFile as readFileCallback } from 'node:fs'; // 1
import { readFile as readFilePromise } from 'node:fs/promises'; // 2
readFileCallback( '/jakis-plik', 'utf-8', ( err, data ) => { // 3
console.log( data ); // 4
} );
const data = await readFilePromise( '/jakis-plik', 'utf-8' );
Starą wersję APi importujemy jako readFileCallback()
(1), natomiast nową – readFilePromise()
(2). Stara wersja przyjmuje trzy parametry (3): ścieżkę do pliku, opcje (w tym przypadku podajemy samo kodowanie pliku) oraz callback. W callbacku wyświetlamy odczytaną zawartość pliku (4). W przypadku obietnicowego API całość sprowadzona jest do jednej linijki (5). Sama funkcja przyjmuje też tylko dwa parametry – wszak callback nie jest potrzebny. Dzięki zastosowaniu tutaj top-level await
przypisujemy zawartość pliku do zmiennej data
– dokładnie tak, jakbyśmy zrobili to w przypadku synchronicznego kodu.
Podobny proces zaszedł w przypadku sporej liczby technologii przeglądarkowych, np. oparty na obietnicach fetch()
w wielu wypadkach wyparł stare, oparte na zdarzeniach API XMLHttpRequest
. Obietnice, zwłaszcza w połączeniu z top-level await
, pozwalają na pisanie kodu asynchronicznego w bardzo podobny sposób, jak kodu synchronicznego. Nie mamy już więcej do czynienia z tzw. piekłem callbacków, kod jest płaski.
Niemniej w przypadku czasomierzy jedyną dostępną opcją są właśnie callbacki, przez co jesteśmy zmuszani do zagnieżdżania kodu. Co więcej, w przypadku nowoczesnego asynchronicznego kodu, opartego na Promise
, mieszanie go z callbackami jest najzwyczajniej w świecie niewygodne:
await someAsyncAction(); // 1
setTimeout( async () => { // 3
await someOtherAsyncAction();
await yetAnotherAsyncAction();
}, 1000 ); // 2
Na początku wywołujemy jakąś asynchroniczną akcję (1). Kolejne dwie muszą być wywołane po jednej sekundzie odstępu (2). To sprawia, że muszą zostać przeniesione do wewnątrz wywołania setTimeout()
(3). W momencie, gdy później pojawić musiałby się kolejny czasomierz, całość zrobiłaby się jeszcze bardziej zagnieżdżona.
Najczęściej taka sytuacja kończy się napisaniem jakiejś pomocniczej funkcji, która otaczałaby callbackowy kod w obietnicę:
await someAsyncAction(); // 1
await wait( 1000 ); // 2
await someOtherAsyncAction(); // 3
await yetAnotherAsyncAction();
async function wait( time ) { // 4
return new Promise( ( resolve ) => { // 5
setTimeout( resolve, time ); // 6
} );
}
Nasz kod wywołuje najpierw pierwszą asynchroniczną akcję (1), następnie naszą pomocniczą funkcję wait()
(2), a następnie – resztę asynchronicznego kodu (3). Z kolei pomocnicza funkcja przyjmuje jako argument czas, który ma odliczyć (4), a następnie zwraca obietnicę (5). Wewnątrz obietnicy wywołujemy setTimeout()
(6). Jako callback przekazujemy funkcję resolve()
obietnicy oraz czas przekazany do samego wait()
. Dzięki temu czasomierz rozwiąże obietnicę, gdy odliczanie czasu się zakończy.
Brak współpracy z AbortController
em
Jak już kiedyś pisałem, AbortController
pozwala na eleganckie przerywanie operacji asynchronicznych “z zewnątrz”. Chyba sztandarowym przykładem jest przerywanie pobierania pliku po przekroczeniu określonego czasu oczekiwania:
( async () => { // 1
const abortController = new AbortController(); // 3
const { signal } = abortController; // 4
setTimeout( () => { // 5
abortController.abort( 'timeout' ); // 7
}, 2000 ); // 6
try { // 2
const response = await fetch( 'https://slowfil.es/file?type=png&delay=10000', { // 8
signal // 9
} );
console.log( response ); // 10
} catch ( err ) {
console.error( err ); // 11
}
} )();
Całość otoczona jest w samowywołującą się funkcję asynchroniczną (1) – wszystko z powodu bloku try
/catch
(2). Wewnątrz funkcji tworzymy AbortController
(3) oraz wyciągamy z niego sygnał (4). Następnie tworzymy czasomierz (5) i ustawiamy go na 2 sekundy (6). Jako callback wywołujemy metodę abort()
naszego AbortController
a (7). Jako powód przerwania operacji podajemy 'timeout'
. Następnie, w bloku try
(2), tworzymy żądanie do pliku .png
(8). Korzystam tutaj z usługi Slowfil.es, która pozwala symulować powolną odpowiedź serwera poprzez ustawienie czasu odpowiedzi. W naszym wypadku obrazek zostanie zwrócony dopiero po 10 sekundach – a więc dawno po odliczeniu czasu przez czasomierz. Do fetch()
a przekazujemy także sygnał jako opcję signal
(9). W przypadku, gdy żądanie się powiedzie, wyświetlamy odpowiedź z serwera (10). W innym wypadku – wyświetlamy błąd (11).
Blok try
/catch
wymusił otoczenie całego kodu w asynchroniczną funkcję, ponieważ top-level await
– jak sama nazwa wskazuje – działa tylko na najwyższym poziomie modułu. Jakiekolwiek zagłębienie jest już poniżej tego poziomu.
Jeśli uruchomimy powyższy kod, zauważymy, że po około dwóch sekundach w konsoli pojawi się błąd 'timeout'
, a żądanie zostanie przerwane i odpowiedź z serwera nigdy nie trafi do konsoli.
Innym ciekawym przykładem zastosowania AbortController
a jest anulowanie wielu operacji asynchronicznych naraz. Zmodyfikujmy nieco nasz przykład:
( async () => {
const abortController = new AbortController();
const { signal } = abortController;
setTimeout( () => {
abortController.abort( 'timeout' );
}, 2000 );
try {
const promise1 = fetch( 'https://slowfil.es/file?type=png&delay=10000', { // 1
signal // 4
} );
const promise2 = fetch( 'https://slowfil.es/file?type=png&delay=20000', { // 2
signal // 5
} );
const promise3 = fetch( 'https://slowfil.es/file?type=png&delay=30000', { // 3
signal // 6
} );
await Promise.all( [ // 7
promise1,
promise2,
promise3
] );
console.log( 'Done' ); // 8
} catch ( err ) {
console.error( err );
}
} )();
Większość kodu pozostała bez zmian. Zamiast jednak jednego żądania, mamy w tym wypadku trzy (1, 2, 3). Są one tworzone na początku przez przypisanie obietnic zwróconych przez fetch()
do zmiennych. Do każdego wywołania fetch()
przekazujemy ten sam sygnał (4, 5, 6). Następnie czekamy na wykonanie się wszystkich żądań przy pomocy Promise.all()
(7). gdy wszystkie żądania się zakończą, w konsoli wyświetlamy napis 'Done'
(8). Tak się jednak nie stanie, bo wszystkie żądania zostaną przerwane przez czasomierz.
Jeśli spojrzymy teraz do zakładki Network w narzędziach developerskich przeglądarki, zauważymy, że, faktycznie, żadne z trzech żądań nie zostało dokończone:

Wyobraźmy sobie jednak inną sytuację: mamy kilka czasomierzy na stronie i w momencie, gdy w jednym z nich dochodzi do błędu, reszta również powinna być anulowana. Brak obsługi AbortController
a wymusza tak naprawdę trzymanie id wszystkich czasomierzy i anulowanie ich pojedynczo:
const timeouts = [
setTimeout( () => { // 2
stopTimeouts(); // 5
}, 1000 ),
setTimeout( () => { // 3
console.log( '#2' );
}, 2000 ),
setTimeout( () => { // 4
console.log( '#3' );
}, 3000 )
];
function stopTimeouts() {
console.log( 'stop' );
timeouts.forEach( ( timeout ) => { // 6
clearTimeout( timeout ); // 7
} );
}
Tworzymy tablicę timeouts
(1), w której umieszczamy trzy wywołania setTimeout()
(2, 3, 4). Dzięki temu tworzymy tablicę zawierającą id wszystkich czasomierzy. W pierwszym z nich symulujemy błąd, wywołując funkcję stopTimeouts()
(5). Funkcja ta iteruje po tablicy timeouts
(6) i dla każdego czasomierza wywołuje funkcję clearTimeout()
(7).
I choć to rozwiązanie działa, jest zdecydowanie mniej eleganckie od obsługi przerywania asynchronicznych aplikacji przy pomocy AbortController
a. Co więcej, robi się zdecydowanie bardziej kłopotliwe w momencie, gdy przerwanie ma nastąpić gdzieś z zewnątrz – z poziomu kodu, który niekoniecznie wie (czy wręcz: powinien wiedzieć), że gdzieś są wykorzystywane czasomierze. W idealnym świecie taki zewnętrzny kod mógłby przesłać sygnał do środka funkcji z asynchroniczną operacją i, w razie potrzeby, użyć go do wymuszenia przerwania tej operacji. W przypadku czasomierzy nie da się tego zrobić bez napisania własnoręcznie tłumaczenia sygnału na odpowiednie wywołania clearTimeout()
.
Nieprzyjazny format czasu
To zarzut zdecydowanie mniejszego kalibru niż dwa poprzednie. Otóż, niekoniecznie lubię się z milisekundami. A dokładniej: z przeliczaniem wszystkiego na milisekundy. W przypadku sekund jeszcze jest łatwo, ale jeśli potrzeba czegoś w minutach, to już się robi kłopotliwie. Dlatego fajnie byłoby móc przekazywać do czasomierzy czas w jakimś innym formacie, np. tekstowym:
setTimeout( () => {}, '1m23s' );
Prawda, że ładniej?
Co dalej?
Skoro znamy już problemy i bolączki natywnych czasomierzy w przeglądarkach, czas pomyśleć, w jaki sposób można by je rozwiązać. Ale to już temat na kolejny wpis!
Komentarze
Przejdź do komentarzy bezpośrednio na Githubie.