Niestandardowe zdarzenia w workerach
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 );

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.

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.

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ł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!
Komentarze
Przejdź do komentarzy bezpośrednio na Githubie.
Dawne komentarze
Ten blog wcześniej korzystał z systemu komentarzy Disqus. Jednakże pożegnaliśmy się i postanowiłem, że zaimportuję do nowej wersji stare komentarze z niego. Cóż, jego system eksportu na wiele nie pozwala…
WOW. Zarąbisty artykuł. Dziękuje za niego!
Jeszcze tak z 2 razy go przeczytam i powinienem w 100% zaczaić. :P
Pozdrawiam!
Wchodzą tu wiedziałem, że znowu dowiem się czegoś nowego :) Chyba jako jedyny w polskich internetach piszesz w faktycznie zaawansowany sposób o JS. Ale brakuje mi jednego tematu, w sumie chyba jednego z ważniejszych, jak wygląda kwestia testowania tych rozwiązań, bo bez tego raczej nie widzę ich w kodzie produkcyjnym...
Dzięki!
Co do testowania, to przyznam szczerze, że nie jest to temat, który spędza mi sen z powiek w przypadku tego typu rozwiązań. Bardziej mnie interesuje, czy moje pomysły w ogóle mają prawo działać ;)
Niemniej w tym wypadku testowanie nie wydaje się specjalnie trudne. Jeśli założymy, że mamy jakiś build system, który składa naszego workera z pojedynczych modułów, to wówczas możemy sobie te moduły standardowo przetestować jak każdą Node'ową bibliotekę. Dopiero samo publiczne API workera (czyli wywoływanie zdarzeń i sprawdzanie, czy jest zwracana odpowiednia odpowiedź do strony) trzeba testować w przeglądarce. Tutaj na pomoc przyszłaby karma.
Muszę to wydrukować i powiesić w biurze, najlepiej tak, aby i testerzy to widzieli :D
fajny artykuł, aby odwrócić kolejność self.on i listener-a wystarczy usunąć drugi addEventListener i wstawić wywołanie self.on przed dispatch
Hmm, przed? Przecież wówczas ten handler odpali się _przed_ wszystkimi listenerami przypiętymi przy pomocy `addEventListener`, nie _po_ wszystkich.
Niemniej można przenieść go po `dispatchEvent`, zważając na fakt, że tak odpalone zdarzenia są synchroniczne (https://developer.mozilla.o... ).
Ah, faktycznie nie doczytałem myślałe, że miały się uruchamiać przed listenerami, w takim razie masz race za `dispatchEvent`, ale chodziło mi, że mogą być w tym pierwszym listenerze to wtedy ten komentarz odnoście tego, że nie działa dokładnie tak jak powinno nie będzie potrzebny.