Broadcast Channel API
Wpis pokazuje jak używać Broadcast Channel API do przesyłania danych między kartami lub oknami przeglądarki bez wykorzystania serwera i socketów.
Daniel Gustaw
• 11 min read
Nauczymy się jak używać Broadcast Channel API
do przesyłania danych między kartami lub oknami przeglądarki bez wykorzystania serwera i socketów.
Parcel Bundler - intuicyjny i prosty builder
Jak zwykle prezentujemy kod od początku do końca. Zaczniemy od instalacji parcela - najprostszego bundler w świecie JS działającego out of the box w przeciwieństwie do webpacka, którego konfiguracja jest po prostu nudna. Parcela instalujemy komendą:
npm install -g parcel-bundler
Tworzymy pliki html
i ts
poleceniami:
echo '<html><body><script src="./index.ts"></script></body></html>' > index.html
touch index.ts
I włączamy nasz serwer
parcel index.html
Podstawy działania Broadcast Channel API
Pokażemy teraz jak w konsoli przeglądarki zobaczyć najprostsze działanie Broadcast Channel Api. W pliku index.ts
inicjalizujemy kanał.
const bc = new BroadcastChannel('channel');
Następnie przypiszemy naszej karcie w przeglądarce losowe ID
const id = Math.random();
Oraz zapiszemy w pamięci liczniki wiadomości wysłanych i odebranych
let send = 0, received = 0;
Jako wiadomość powitalną wyświetlimy id wybrane dla naszej karty
console.log("START", id);
Następnie ustawiamy nasłuch na wiadomości
bc.onmessage = (e) => {
console.log(e.data, send, received);
received++;
}
Podnosimy w nim licznik wiadomości odebranych oraz pokazujemy przysłane dane oraz wartości liczników w danej karcie.
Teraz czas na wysyłanie wiadomości do kanału. Służą do tego funkcje postMessage
.
Chwilę po włączeniu karty chcemy wysłać wiadomość powitalną do innych kart
setTimeout(() => {
bc.postMessage({title: `Connection from ${id}`})
}, 250)
Timeout pozwala poczekać na to, żeby inne karty się przeładowały. Gdyby nie on, to na kartach które nie są gotowe kiedy ta wiadomość jest wysyłana nie zobaczyli byśmy console loga.
Następnie chcemy wysłać jeszcze dwie wiadomości, które przestawią nam liczniki wysłań
const i = setInterval(() => {
const uptime = performance.now();
bc.postMessage({id, uptime, send, received})
send++;
if (uptime > 1e3) clearInterval(i)
}, 500)
Przy okazji użyliśmy tu innego API - performance:
Performance - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/Performance)
Dla dwóch kart w możemy zobaczyć, że w każdej karcie widać jej odrębny identyfikator i wiadomości wysłane z przeciwnej karty.
Nic nie stoi na przeszkodzie, żebyśmy włączyli cztery karty na raz. Wtedy wiadomości od trzech pozostałych w każdej z nich będą się wzajemnie przeplatać.
Możemy wrócić do dwóch kart i odświeżyć kilka razy tą z prawej strony. W wyniku takiego działania ta po lewej dostanie kilkukrotnie nowe powiadomienia, a na tej prawej nie będzie widać nic poza jej własnym przedstawieniem się ponieważ lewa karta zakończyła już nadawanie wiadomości. Konkretny wynik odświeżania prawej karty przedstawia screenshot:
Widzimy tu, że wiadomości pochodzą od różnych ID, bo karta po prawej zmienia ID przy każdym odświeżeniu.
Kolejny eksperyment to sprawdzenie czy Broad Cast Channel działa między różnymi przeglądarkami:
Okazało się, że nie. Ma to sens, bo jeśli miało by działać między przeglądarkami, to musiała by istnieć komunikacja między procesami utrzymującymi przeglądarki.
Zasada Same Origin
Broadcast Channel ma zasięg działania w dla wszystkich kart, przeglądarek, iframes w ramach tego samego Origin czyli schematu (protokołu), hosta i portu.
Więcej o samym Origin możemy przeczytać w słowniku Mozilla Developers
Origin - MDN Web Docs Glossary: Definitions of Web-related terms
Sprawdzimy czy dla różnych komputerów też będzie działał poprawnie. W tym celu musimy zmienić ustawienia parcela, bo obecnie wystawia on nasz serwis na localhost
Nasz obecny adres IP możemy sprawdzić poleceniem
ip route
Z dokumentacji możemy wyczytać, że wystarczy dodanie flagi --host
parce index.html --host 192.168.2.162
Okazało się, że komunikacja nie jest przesyłana między różnymi komputerami.
Jest to zgodne z intuicją. O ile w przypadku Web Socketów istnieje jakiś serwer do utrzymywania (czy nawet WebRCT do samego nawiązywania) połączenia, to tutaj jedyną warstwą transportu danych jest pamięć operacyjna komputera na którym używany jest Broadcast Channel.
Broadcast Channel API a Shared Workers, Message Channel i post Message
Być może zastanawiasz się jaka jest różnica między omawianym API a innymi metodami komunikacji między kontekstami jak:
- Shared Workers
- Message Channel
- window.postMessage()
W przypadku SharedWorkers możesz zrobić to samo co za pomocą BroadcastChannel ale wymaga to większej ilości kodu. Zalecam używanie SharedWorkers do bardziej zaawansowanych zdań jak zarządzanie blokadami, współdzielenie stanu, synchronizacja zasobów czy dzielenie połączenia WebSocket między kartami.
Natomiast Broadcast Channel Api jest wygodniejsze w prostych przypadkach, kiedy chcemy wysłać wiadomość do wszystkich okien, zakładek lub workerów.
Co do MessageChannel API to główna różnica polega na tym, że w MessageChannel API wysyła się wiadomość do jednego odbiorcy, podczas gdy w Broadcast Channel wysyłający jest jeden, a odbiorcami są zawsze wszystkie pozostałe konteksty.
W window.postMessage wymagane jest z kolei utrzymywanie referencji do obiektu iframe lub workera, żeby nadawać komunikację, na przykład:
const popup = window.open('https://another-origin.com', ...);
popup.postMessage('Sup popup!', 'https://another-origin.com');
Z drugiej strony trzeba też pilnować, żeby przy odbieraniu sprawdzić źródło wiadomości ze względów bezpieczeństwa:
const iframe = document.querySelector('iframe');
iframe.contentWindow.onmessage = function(e) {
if (e.origin !== 'https://expected-origin.com') {
return;
}
e.source.postMessage('Ack!', e.origin);
};
Pod tym względem Broadcast Channel jest bardziej ograniczony, bo nie pozwala na komunikację między różnymi Origin, ale zapewnia to domyślnie wyższe bezpieczeństwo. Z drugiej strony window.postMessage nie pozwalał na wysyłkę do innych okien bo nie można do nich było złapać referencji.
Rysowanie na Canvas w niezależnych kartach
Czas na praktyczny przykład. No może nie super użyteczny, ale za to dobrze prezentujący możliwości Broadcast Channel API.
Zaprogramujemy aplikację pozwalającą na przenoszenie rysowanych kształtów na płótnie między kartami przeglądarki.
Zaczniemy od zwykłego rysowania myszką na canvas. W tym celu zmienimy nasz kod index.html
dodając do niego płótno i niezbędne style
<html lang="en">
<body style="margin:0;">
<canvas id="canvas" style="width: 100vw; height: 100vh;"></canvas>
<script src="./index.ts"></script>
</body>
</html>
W skrypcie index.ts
wpisujemy
interface Window {
canvas?: HTMLCanvasElement;
}
Pozwoli nam to na trzymanie canvasu w oknie. Aby nie wyszukiwać go wiele razy możemy użyć window
jako cache w którym będziemy go trzymać po pierwszym znalezieniu.
const getCanvasAndCtx = (): { canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D } => {
const canvas = window.canvas || document.querySelector('#canvas');
if (canvas instanceof HTMLCanvasElement) {
window.canvas = canvas;
const ctx = canvas.getContext('2d');
if(ctx) {
return {canvas, ctx}
} else {
throw new Error('Canvas do not have context');
}
}
throw new Error('Canvas Not found');
}
W celu dostrojenia wielkości canvasu deklarujemy funkcję syncCanvasSize
const syncCanvasSize = () => {
const { canvas } = getCanvasAndCtx()
canvas.height = window.innerHeight;
canvas.width = window.innerWidth;
}
Wykonamy ją przy każdym evencie resize
na window
oraz po załadowaniu strony
window.addEventListener('resize', syncCanvasSize)
window.addEventListener('DOMContentLoaded', () => {
syncCanvasSize();
const {canvas, ctx} = getCanvasAndCtx()
Definiujemy kilka parametrów do określania stanu i historii kursora.
let flag = false,
prevX = 0,
currX = 0,
prevY = 0,
currY = 0;
Następnie definiujemy funkcje drawLine
rysującą linię oraz drawDot
rysującą kropkę
function drawLine() {
ctx.beginPath();
ctx.moveTo(prevX, prevY);
ctx.lineTo(currX, currY);
ctx.strokeStyle = "black";
ctx.lineWidth = 2;
ctx.stroke();
ctx.closePath();
}
function drawDot() {
ctx.beginPath();
ctx.fillStyle = 'black';
ctx.fillRect(currX, currY, 2, 2);
ctx.closePath();
}
Oraz najważniejszą funkcję findPosition
- sterującą logiką rysowania
function findPosition(res: EventType, e: { clientX: number, clientY: number }) {
if (res == EventType.down) {
prevX = currX;
prevY = currY;
currX = e.clientX;
currY = e.clientY;
flag = true;
drawDot()
}
if ([EventType.up, EventType.out].includes(res)) {
flag = false;
}
if (res == EventType.move) {
if (flag) {
prevX = currX;
prevY = currY;
currX = e.clientX;
currY = e.clientY;
drawLine();
}
}
}
Na końcu dodajemy nasłuch na wydarzenia powiązane z myszą aby używać funkcji findPosition
canvas.addEventListener("mousemove", (e) => {
findPosition(EventType.move, e)
});
canvas.addEventListener("mousedown", (e) => {
findPosition(EventType.down, e)
});
canvas.addEventListener("mouseup", (e) => {
findPosition(EventType.up, e)
});
canvas.addEventListener("mouseout", (e) => {
findPosition(EventType.out, e)
});
})
Powyższy kod pozwala nam to na rysowanie na canvasie w ramach pojedynczej karty. Żeby było możliwe przenoszenie obrazu między kartami wykorzystamy Broadcast Channel.
Wymagana będzie jego inicjalizacja:
const bc = new BroadcastChannel('channel');
Dodanie nasłuchu na polecenie findPosition
.
bc.onmessage = (e) => {
if(e.data.cmd === 'findPosition') {
findPosition(e.data.args[0], e.data.args[1], false)
}
}
Do samej funkcji findPosition
dodaliśmy trzeci argument - propagate
mówiący czy wywołanie tej funkcji ma powodować wysłanie wiadomości do kanału. Wartość false
pozwala unikną nieskończonego zagnieżdżenia.
Na końcu zmieniamy sygnaturę samej funkcji findPosition
tak jak to opisaliśmy i dodajemy fragment kodu odpowiedzialny za wysyłkę wiadomości do innych kart
function findPosition(res: EventType, e: {clientX: number, clientY: number}, propagate: boolean) {
if(propagate) {
bc.postMessage({cmd: 'findPosition', args: [res, {clientX: e.clientX, clientY: e.clientY}]})
}
Warto zauważyć, że nie przekazujemy tu pełnych obiektów event
a jedynie współrzędne. Jest to nie tylko optymalizacja. Klonowanie takich obiektów jak Event nie jest możliwe między kontekstami.
Cały kod zawarty w index.ts
prezentuję poniżej:
interface Window {
canvas?: HTMLCanvasElement;
}
const getCanvasAndCtx = (): { canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D } => {
const canvas = window.canvas || document.querySelector('#canvas');
if (canvas instanceof HTMLCanvasElement) {
window.canvas = canvas;
const ctx = canvas.getContext('2d');
if(ctx) {
return {canvas, ctx}
} else {
throw new Error('Canvas do not have context');
}
}
throw new Error('Canvas Not found');
}
const syncCanvasSize = () => {
const {canvas} = getCanvasAndCtx()
canvas.height = window.innerHeight;
canvas.width = window.innerWidth;
}
window.addEventListener('resize', syncCanvasSize)
enum EventType {
down,
up,
move,
out
}
window.addEventListener('DOMContentLoaded', () => {
syncCanvasSize();
const {canvas, ctx} = getCanvasAndCtx()
let flag = false,
prevX = 0,
currX = 0,
prevY = 0,
currY = 0;
const bc = new BroadcastChannel('channel');
function drawLine() {
ctx.beginPath();
ctx.moveTo(prevX, prevY);
ctx.lineTo(currX, currY);
ctx.strokeStyle = "black";
ctx.lineWidth = 2;
ctx.stroke();
ctx.closePath();
}
function drawDot() {
ctx.beginPath();
ctx.fillStyle = 'black';
ctx.fillRect(currX, currY, 2, 2);
ctx.closePath();
}
function findPosition(res: EventType, e: { clientX: number, clientY: number }, propagate: boolean) {
if (propagate) {
bc.postMessage({cmd: 'findPosition', args: [res, {clientX: e.clientX, clientY: e.clientY}]})
}
if (res == EventType.down) {
prevX = currX;
prevY = currY;
currX = e.clientX;
currY = e.clientY;
flag = true;
drawDot()
}
if ([EventType.up, EventType.out].includes(res)) {
flag = false;
}
if (res == EventType.move) {
if (flag) {
prevX = currX;
prevY = currY;
currX = e.clientX;
currY = e.clientY;
drawLine();
}
}
}
canvas.addEventListener("mousemove", (e) => {
findPosition(EventType.move, e, true)
});
canvas.addEventListener("mousedown", (e) => {
findPosition(EventType.down, e, true)
});
canvas.addEventListener("mouseup", (e) => {
findPosition(EventType.up, e, true)
});
canvas.addEventListener("mouseout", (e) => {
findPosition(EventType.out, e, true)
});
bc.onmessage = (e) => {
if (e.data.cmd === 'findPosition') {
findPosition(e.data.args[0], e.data.args[1], false)
}
}
})
Aplikacja działa tak, że obraz rysowany w jednej karcie pojawia się we wszystkich pozostałych:
Zastosowania Broadcast Channel API
Przykładowa aplikacja pokazuje, że broadcast channel może być stosowany w bardzo wygodny sposób. Zapewnienie synchronizacji między kartami zostało wprowadzone przez dodanie 9 linii kodu z czego 3 to domknięcia nawiasów klamrowych.
Jego przykładowe zastosowania to:
- Wykrywanie akcji użytkownika w innych zakładkach
- Sprawdzanie kiedy użytkownik zalogował się na swoje konto w innej zakładce lub oknie
- Zlecenie Workerom wykonania jakichś zadań w tle
- Rozsyłanie zdjęć załadowanych przez użytkownika w innych kartach
Jeśli potrzebujemy komunikacji między komputerami to Broadcast Channel API nam nie pomoże i wtedy do komunikacji w czasie rzeczywistym należy użyć WebSockets lub WebRTC.
Polecane materiały oraz dokumentacja:
Broadcast Channel API - Web APIs | MDN
BroadcastChannel API: A Message Bus for the Web | Google Developers
Other articles
You can find interesting also.
Wybrane składnie w JavaScript ES2020, ES2021 i ES2022
Nullish coalescing, Opcjonalne łańcuchowanie, Proxies, Pola prywatne, allSettled, BigInt, Dynamiczny import, replaceAll, Separatorzy numeryczni, matchAll, Przypisanie logiczne, Await na najwyższym poziomie
Daniel Gustaw
• 19 min read
Pulumi - Infrastruktura jako kod [ Digital Ocean ]
Za pomocą Pulumi możesz zdefiniować swoją infrastrukturę IT w pliku opisanym za pomocą twojego ulubionego języka programowania. Ten artykuł pokazuje, jak to zrobić.
Daniel Gustaw
• 10 min read
Wzorzec pull-push ZeroMQ dla Node JS
Artykuł podkreśla elastyczność ZeroMQ w zakresie przesyłania wiadomości w Node.js, zwracając uwagę na wzór pull-push, idealny dla rozproszonych systemów o dużej wydajności.
Daniel Gustaw
• 3 min read