W przypadku skryptów korzystających z DOM stworzenie własnych, niestandardowych zdarzeń jest banalnie proste i sprowadza się do utworzenia nowej instancji CustomEvent. Ten sposób jednak nie (do końca) działa w przypadku workerów, które nie mają dostępu do DOM. Co zatem zrobić w takim wypadku?

Zaraz, zaraz – workery…?

JS jest jednowątkowy – to od wieków znana i (raczej) niekwestionowana prawda. Niemniej współczesne procesory od lat potrafią radzić sobie z większą liczbą wątków i tak trochę żal z tego nie skorzystać. Zwłaszcza, że w internecie dzieje się coraz większa część naszego życia. Z tego też powodu powstały Web Workers (Robotnicy Sieciowi).

Założenie jest bardzo proste: w głównym wątku (czyli tym, który odpowiada za renderowanie tego, co widzi użytkownik) tworzymy workera, który “osiedla się” w kolejnym wątku i tam radośnie sobie działa. Tym sposobem nawet jeśli wykonuje jakieś skomplikowane, długotrwające operacje, użytkownik tego nie odczuje. Istnieje też prosty mechanizm komunikacji pomiędzy workerem a główną stroną, przy pomocy postMessage (analogicznie jak w przypadku komunikacji strony z osadzoną ramką iframe).

Istnieje też specjalny typ workerów, tzw. Service Workers (Robotnicy Usługowi). Tak jak typowy Web Worker działa od czasu otwarcia strony aż do jej zamknięcia, tak Service Worker działa od czasu instalacji poprzez skrypt na stronie aż do jego ręcznego usunięcia przez użytkownika lub wymuszenia odinstalowania po stronie skryptu. Innymi słowy: Service Worker jest połączony z konkretną stroną, ale działa nieustannie w tle, nawet gdy dana strona jest już zamknięta. Dodatkowo Service Worker jest w stanie przechwytywać wszystkie żądania do konkretnej strony (przy pomocy zdarzenia fetch), co sprawia, że w praktyce działa niczym proxy wbudowane w przeglądarkę.

Używanie workerów sprawia, że nasza aplikacja webowa zaczyna działać wielowątkowo, co pozwala przenieść sporą część logiki z głównego wątku do wątków pomocniczych. A to z kolei otwiera drogę ciekawym optymalizacjom.

Problem: niejasna komunikacja

API do obsługi workerów jest bardzo proste. Najprostsze użycie workera to stworzenie instancji klasy Worker:

const worker = new Worker( 'worker.js' );

Jako parametr konstruktora musimy podać adres skryptu, w którym znajduje się kod naszego workera. Wyobraźmy sobie, że jego zadaniem będzie zaczekanie na informację przesłaną ze strony głównej, obrobienie jej i zwrócenie wyniku. W tym celu musimy się posłużyć wcześniej wspomnianą funkcją postMessage. Na głównej stronie musimy dodać kod do obsługi danych przesłanych z workera oraz wysłać dane do workera:

worker.addEventListener( 'message', ( { data } ) => { // 1
    console.log( 'main thread', data ); // 2
} );

worker.postMessage( 'myData' ); // 3

Każde przychodzące dane odpalają zdarzenie message na obiekcie workera (1). Zdarzenie to zawiera własność data przechowującą przesłane dane. Nie robimy z nimi nic interesującego, po prostu je rzucamy do konsoli (2). Będąc przygotowanym na odpowiedź workera, możemy mu przesłać dane – w tym wypadku prosty tekst 'myData' (3).

Analogiczny kod znajduje się po stronie workera:

self.addEventListener( 'message', ( { data } ) => { // 1
    console.log( 'worker thread', data ); // 2

    self.postMessage( `${ data } – worker processed` ); // 3
} );

Tym razem przypinamy się do globalnego obiektu w workerze, self (1; jest to odpowiednik window ze strony) i nasłuchujemy zdarzenia message. Gdy dane przychodzą, rzucamy je do konsoli (2). Na samym końcu doklejamy do otrzymanych danych ciąg ' – worker processed' i przesyłamy to jako wiadomość do self, co spowoduje odesłanie danych do głównego wątku (3). Prosty przykład.

Tego typu sposób pracy z workerami jest bardzo prosty i intuicyjny, lecz ma bardzo poważne ograniczenia. Gdy spojrzymy na DOM, zauważymy, że niemal wszystko ma swoje dedykowane zdarzenie. Dzięki temu bez problemu odróżnimy moment, w którym użytkownik wcisnął przycisk myszy (mousedown), od momentu, w którym go puścił (mouseup). Można się wręcz pokusić o stwierdzenie, że nazwy zdarzeń DOM stanowią prosty DSL. Nietrudno sobie wyobrazić, jak mało intuicyjne stałoby się posługiwanie się DOM-em (który i tak do najbardziej intuicyjnych nie należy), gdyby np. myszka generowała tylko jedno zdarzenie – mouse – i musielibyśmy robić dziwne, heurystyczne wygibasy, by stwierdzić, czy to przycisk został wciśnięty albo może użytkownik nieco poruszył kursorem.

Taka sytuacja, niestety, występuje w workerach, w których na dobrą sprawę mamy do czynienia wyłącznie ze zdarzeniem message. Inne zdarzenia, jakie mogą zajść (jak choćby wspomniane fetch w Service Workerze), są w pełni niezależne od nas jako programistów i nie mamy kontroli nad tym, kiedy się odpalają. Dodatkowo posiadanie tylko message sprawia, że dość trudno jest podzielić przesyłane dane na typy, np. żądania dociągnięcia dodatkowych danych z API i żądania obliczenia 145 cyfry liczby π. Wszystko zaczyna się zlewać w jedno i nawet, jak podzielimy kod na ładne, oddzielne listenery na message, to będziemy grzęznąć w gąszczu if-ów sprawdzających, czy na pewno ten rodzaj wiadomości chcemy w danej funkcji obsłużyć.

Stąd pojawia się potrzeba obsługi niestandardowych zdarzeń w workerach.

Naiwna próba

Skoro nasz obiekt worker posiada metodę addEventListener, to prawdopodobnie posiada też metodę dispatchEvent, służącą do odpalania zdarzeń. Obydwie są bowiem zadeklarowane na tym samym interfejsie – EventTarget, a obiekty klasy Worker implementują interfejs EventTarget:

(new Worker( '' ) ) instanceof EventTarget; // true

W specyfikacjach wykorzystuje się pseudojęzyk WebIDL do opisu zależności pomiędzy poszczególnymi klasami i obiektami definiowanymi przez różne standardy. Stąd pojawiają się też interfejsy czy mixiny, które normalnie w JS nie występują.

Zatem obsługa niestandardowych zdarzeń powinna być banalnie prosta i sprowadzać się do:

const event = new CustomEvent( 'mycustomevent' );
worker.dispatchEvent( event );

Sprawdźmy zatem po stronie workera, czy otrzymujemy zdarzenie:

self.addEventListener( 'mycustomevent', console.log );

Nic, cisza w konsoli…

Wynika to z prostego faktu: obiekt worker w głównym wątku i self w workerze to totalnie dwa różne obiekty. Ten pierwszy nie jest faktycznym obiektem workera, a jedynie prostym interfejsem do komunikacji z workerem. Co najważniejsze, działa tak samo jak inne klasy implementujące EventTarget, czyli m.in. elementy DOM. Jeśli tworzymy sztuczne zdarzenie DOM (np. click) nie oczekujemy, że nagle element na innej stronie zostanie kliknięty. A przecież tak należy postrzegać workera – jako coś, co zachowuje się jak inna strona, niezależnie od strony z obiektem worker. To oznacza, że zdarzenie wysłane do obiektu worker odbierzemy na stronie, na której je wysłaliśmy:

worker.addEventListener( 'mycustomevent', console.log );

const event = new CustomEvent( 'mycustomevent' );
worker.dispatchEvent( event );
Informacja o zaszłym zdarzeniu w konsoli Chrome

Jak widać, w konsoli wyświetliły się informacje o zdarzeniu oraz informacja o tym, że zaszło w pliku test.html, nie zaś – w pliku worker.js. To oznacza, że faktycznie zdarzenia odpalone na obiekcie worker nie wychodzą poza stronę. Przykład na żywo, jakby ktoś nie wierzył.

Trzeba zatem pomyśleć o jakimś innym sposobie…

Emulowanie zdarzeń przy pomocy wiadomości

Jak wcześniej wspomniałem, praktycznie jedynym sposobem na przekazanie czegokolwiek do workera jest wysłanie do niego wiadomości przy pomocy metody postMessage. Sposób ten wyzwala zdarzenie message. Sprawdźmy zatem, czy uda nam się w taki sposób przesłać zdarzenie. Zmieńmy kod po stronie workera na:

self.addEventListener( 'message', console.log );

Kod po stronie strony też musimy zmienić:

const event = new CustomEvent( 'mycustomevent' );
worker.postMessage( event );

Sprawdźmy, co się stanie.

Błąd w konsoli Chrome: "Uncaught DOMException: Failed to execute 'postMessage' on 'Worker': CustomEvent object could not be cloned."

Z racji tego, że przekazanie do workera danych bezpośrednio mogłoby stanowić lukę w zabezpieczeniach, wszystkie dane są tak naprawdę klonowane przed wysłaniem i worker otrzymuje ich dokładne kopie. Niestety, niektórych obiektów nie da się sklonować. Należą do nich wszystkie te obiekty, które posiadają metody. A zdarzenia mają mnóstwo metod (preventDefault, stopPropagation, stopImmediatePropagation itd.). Tym samym nie możemy posłać zdarzenia bezpośrednio, musimy je wcześniej zmienić na zwykły obiekt. Najprostszy sposób na taką transformację obiektów to przepuszczenie ich przez JSON.stringify + JSON.parse. Niemniej w przypadku zdarzenia da to raczej nieoczekiwany rezultat:

{
	isTrusted: false
}

Utraciliśmy wszystkie informacje o zdarzeniu, oprócz tej, że jest niezaufane. Wynika to z faktu, że tylko ta własność jest przypięta bezpośrednio do zdarzenia, a cała reszta – do prototypu. A jak wiadomo, JSON.stringify do prototypów nie zagląda. Kolejny plan spalił na panewce…

Niemniej rozwiązanie tego problemu jest proste: wystarczy stworzyć obiekt imitujący zdarzenie, a następnie niech już worker się martwi, co z tym dalej zrobić!

const event = {
    type: 'event',
    name: 'mycustomevent'
};
worker.postMessage( event );

Tego typu obiekt bez problemu zostaje przesłany do workera. Tylko co dalej?

Jak wspominałem, worker nie ma dostępu to DOM. To prawda, ale równocześnie ma dostęp do konstruktora CustomEvent! Tym samym możemy go użyć do odtworzenia zdarzenia po stronie workera:

self.addEventListener( 'message', ( { data: { type, name } = {} } ) => { // 1
	if ( type !== 'event' ) { // 2
		return;
	}

	const event = new CustomEvent( name ); // 3
	self.dispatchEvent( event ); // 4
} );

Przypinamy się do zdarzenia message i pobieramy z niego wyłącznie data.type i data.name (1; ten superdziwny zapis w parametrach to zagnieżdżona destrukturyzacja połączona z domyślnym parametrem; tak, wiem…). Chcemy obsłużyć tylko wiadomości, których data.type jest równe event, więc odsiewamy resztę (2). Następnie tworzymy nowe zdarzenie (3) i je wyzwalamy (4).

Dodajmy zatem jeszcze listener do workera:

self.addEventListener( 'mycustomevent', console.log );

Sprawdźmy, czy całość działa.

Obiekt zdarzenia zalogowany w konsoli Chrome

Działa! I wcale nie zajęło to dużo kodu. Dodajmy zatem możliwość przekazywania danych do zdarzenia. Po stronie strony wystarczy je przekazać jako nową własność wysyłanego obiektu:

const event = {
    type: 'event',
    name: 'mycustomevent',
    detail: 'whatever'
};
worker.postMessage( event );

Natomiast po stronie workera wypada dodać nową własność do własności detail zdarzenia (tak się bowiem przekazuje dane do niestandardowego zdarzenia):

self.addEventListener( 'message', ( { data: { type, name, detail } = {} } ) => {
	if ( type !== 'event' ) {
		return;
	}

	const event = new CustomEvent( name, {
		detail
	} );
	self.dispatchEvent( event );
} );

Voilà! Została już tylko jedna rzecz do dopieszczenia: utworzenie przyjemnej funkcji do wywoływania zdarzenia w workerze z poziomu strony:

Object.defineProperty( Worker.prototype, 'raiseEvent', {
    value( detail ) {
        const event = {
            type: 'event',
            name: 'mycustomevent',
            detail
        };
        this.postMessage( event );
    }
} );

const worker = new Worker( 'worker.js' );

worker.raiseEvent( 'whatever' );

Zastosowałem tutaj Object.defineProperty, żeby nowa metoda nie była widoczna na zewnątrz (czyli zachowywała się tak jak natywne). Tym sposobem cała logika związana z tworzeniem zdarzenia jest zamknięta w metodzie raiseEvent, do której przekazujemy wyłącznie dodatkowe dane zdarzenia. Przykład na żywo.

Bonus: globalny nasłuchiwacz

Gdy przyjrzymy się globalnemu obiektowi w workerze dostrzeżemy, że oprócz addEventListener posiada on także globalną wlasność onmessage. Wypada więc dodać także taką u nas. Jest to dość proste, bo wystarczy dodać nowy listener dla 'mycustomevent', który będzie sprawdzał, czy self.onmycustomevent jest funkcją i jeśli tak, to wywoływał ją w kontekście self ze zdarzeniem przekazanym jako parametr:

self.addEventListener( 'mycustomevent', ( evt ) => {
	if ( typeof self.onmycustomevent === 'function' ) {
		self.onmycustomevent.call( self, evt );
	}
} );

Dodajmy zatem nowy globalny listener:

self.onmycustomevent = console.warn;

Sprawdźmy, czy wszystko działa:

Działanie globalnego listenera w konsoli Chrome: zostało wyświetlone ostrzeżenie zawierające obiekt zdarzenia

Działa!

Jedyny problem z naszą implementacją onmycustomevent to fakt, że jest wywoływany przed innymi listenerami. Tradycyjnie listenery przypięte przez on… wywołują się na końcu. Wydaje mi się jednak, że tego typu szczegół – zwłaszcza przy własnej implementacji zdarzeń – jest mało istotny i można go pominąć.

Ostateczna refaktoryzacja

Dopieśćmy zatem ostatecznie naszą obsługę niestandardowych zdarzeń w workerze, wydzielając ją do osobnej funkcji:

function registerCustomEvent( eventName ) { // 1
	self.addEventListener( 'message', ( { data: { type, name, detail } = {} } ) => { // 2
		if ( type !== 'event' && name !== eventName ) { // 3
			return;
		}

		const event = new CustomEvent( name, {
			detail
		} );
		self.dispatchEvent( event );
	} );

	self.addEventListener( eventName, ( evt ) => { // 4
		if ( typeof self[ `on${ eventName }` ] === 'function' ) {
			self[ `on${ eventName }` ].call( self, evt );
		}
	} );
}

registerCustomEvent( 'mycustomevent' ); // 5

Do naszej nowej funkcji registerCustomEvent przekazujemy nazwę zdarzenia, jakie chcemy zarejestrować (1). Jest dla niego tworzony nowy listener message (2), który jest lekko zmienioną wcześniejszą wersją. Sprawdzamy bowiem, czy wiadomość, która przyszła przedstawia konkretny typ zdarzenia, rozpoznawany po nazwie (3). Zmianie uległ też listener obsługujący on<nasza nazwa>, gdyż teraz nazwa zdarzenia jest do niego przekazywana z zewnątrz, przez zmienną eventName (4). Cała reszta pozostała bez zmian. Naszą nową funkcję wywołujemy po prostu podając jej nazwę nowego zdarzenia (5).

Tę funkcję możemy wydzielić do nowego pliku, np. registerCustomEvent.js, a następnie dołączyć na samym początku workera, używając importScripts. Jest to odpowiednik require z Node.js czy też znacznika script:not([async]) z HTML-a.

self.importScripts( 'registerCustomEvent.js' );

Możemy też przerobić nieco kod w głównym wątku – tak, aby również był uniwersalny:

function registerCustomEventDispatcher( dispatcherName, eventName ) { // 1
    Object.defineProperty( Worker.prototype, dispatcherName, { // 2
        value( detail ) {
            const event = {
                type: 'event',
                name: eventName, // 3
                detail
            };
            this.postMessage( event );
        }
    } );
}

registerCustomEventDispatcher( 'raiseEvent', 'mycustomevent' ); // 4

Tym razem stworzyliśmy funkcję z dwoma parametrami: nazwą metody wywołującej zdarzenie oraz nazwą samego wywoływanego zdarzenia (1). Cała reszta funkcji praktycznie się nie zmieniła, oprócz podstawienia parametrów w odpowiednie miejsca (2, 3). W naszym wypadku wywołujemy funkcję, podając jako nazwę funkcji raiseEvent a jako nazwę zdarzenia – mycustomevent (4). Tę funkcję, dla estetyki, również można przenieść do osobnego pliku (np. registerCustomEventDispatcher.js).

Tym sposobem uzyskaliśmy minimalistyczną bibliotekę przeznaczoną do tworzenia własnych, niestandardowych zdarzeń w workerach! Przykład na żywo.

Jeśli zastanawiasz się, czy ten przykład zadziała także we wspomnianych Service Workerach, to śpieszę powiedzieć, że jak najbardziej! Prawdopodobnie zajdzie jedynie konieczność dodania naszej metody raiseEvent również do ServiceWorker.prototype.

Podejście alternatywne

Istnieje także inne podejście do problemu – o wiele bardziej skomplikowane i (moim zdaniem) niewarte zachodu. Chodzi o nadpisanie metod addEventListener i removeEventListener. Jedyną przewagę tego sposobu nad pokazanym w tym artykule jest możliwość stworzenia całkowicie niezależnego obiegu zdarzeń, który nie opiera się na self.dispatchEvent. Niemniej jest stosunkowo mało przypadków, gdy jest to faktycznie potrzebne.

I to by było na tyle. Miłego dodawania niestandardowych zdarzeń do swoich workerów!