In the last lesson we discussed how to approach building a cross-tab messaging application using LocalStorage. Today we'll be doing the same, however, this time we'll be using a SharedWorker. SharedWorkers are Web Workers that are sharable across browser instances (tabs, windows, etc). Because they're Web Workers this means all the code within our worker will run on a different thread in the browser from our application code. So, if you have heavy logic to run in your cross-tab communications this will be a great approach.
We can dispatch messages to and from our workers using a method called postMessage(message)
, which accepts the message we want to post. Inversely, we can listen for messages using an event called onmessage
. This event will be passed an event argument that contains the data we sent via postMessage
.
What We'll Be Building
Interested in what we'll be building? You can check it out here. You'll be able to post a message from one tab and receive it in another. Messages sent from one tab will appear to the right and will be blue. Messages received from other tabs will be gray and to the left, similar to texting.
Following Along?
If you'd like to follow along, I have a repository setup where we'll be starting in this lesson. It's got the project structure and boilerplate markup for our page so we don't have to waste time adding that in. So, go ahead and clone it down, install the dependency (serve), and run npm run serve
to get the dev server started up.
https://github.com/jagr-co/cross-tab-communication/tree/00_StartHere
In this lesson, we'll be working within pages -> sharedworker -> index.html
.
Cross-Tab Communication
Let's start with why you're here, cross-tab communication using a SharedWorker. To define a SharedWorker we need to point the SharedWorker constructor to a script containing our worker code. So, let's start by creating that script so we can instantiate our SharedWorker.
Let's create a new file at /pages/scripts/sharedworker.js
.
Inside our worker script, we'll need a method called onconnect
which, when called, is passed an event. Our onconnect
function will be called anytime a new browser instance connects to our worker.
onconnect = function(e) { }
Copied!
- pages
- scripts
- sharedworker.js
Inside our event resides a ports
array containing an array of the ports that trigger the onconnect
call. We'll want to grab the first port from this array. This port is what we'll use to listen to and post messages to the port's connected browser instance.
onconnect = function(e) { const port = e.ports[0]; }
Copied!
- pages
- scripts
- sharedworker.js
Sending & Receiving Data
In order to accept a message, we'll need to listen for messages on this port using the onmessage
event listener. When our onmessage
listener is called it'll be passed an event and this event will contain our message's data.
If the purpose of your worker isn't to just consume data, you'll likely want to store/manipulate the data and then post it back out for consumption by your application. To do this, we can call postMessage
off our port.
const messages = []; onconnect = function(e) { const port = e.ports[0]; port.onmessage = function(event) { const message = event.data; // store/manipulate your message messages.push(message); // post message back out to your application port.postMessage(messages); } }
Copied!
- pages
- scripts
- sharedworker.js
Connecting To Multiple Browser Instances
Every time we connect to a port, you can think of this as connecting to a browser instance. The same applies when dispatching messages to ports. So, in our above example, we're really only posting our message out to the browser instance where the message originated from. In order to change this, we can store all our connected browser instances (ports) in our worker. Then whenever we send out a new message we'll post this message across all stored browser instances.
const browserInstances = []; const messages = []; onconnect = function(e) { const port = e.ports[0]; // store the newly connected browser instance browserInstances.push(port); port.onmessage = function(event) { const message = event.data; // store/manipulate your message messages.push(message); // post message back out to your application browserInstances.forEach(instance => { instance.postMessage(message); }); } }
Copied!
- pages
- scripts
- sharedworker.js
Connecting To Our Worker
Now that our example worker is all setup, we’re ready to connect to our worker from our application.
// pages/sharedworker/index.html const worker = new SharedWorker('scripts/sharedworker.js');
Copied!
Sending & Receiving Data
Sending data to and receiving data from our worker works the same as it does inside our worker. We’ll listen for messages using onmessage and we’ll send data using postMessage.
// pages/sharedworker/index.html const worker = new SharedWorker('scripts/sharedworker.js'); worker.port.onmessage = function(event) { const message = event.data; } worker.port.postMessage('it lives!');
Copied!
Now that we’re familiar with how to perform cross-tab communication using SharedWorkers, we can move on to creating our application.
Setting Up Our SharedWorker
To start with, let's get our SharedWorker script set up and ready to go. In order to allow for messages to have different purposes, we'll define some mutations our worker will accept. These mutations will define the purpose of a received message.
const mutations = { ADD: 'ADD', SET: 'SET' }
Copied!
- pages
- scripts
- sharedworker.js
We'll use the 'ADD' mutation when we're adding a single record and the 'SET' mutation when we're setting the entire collection.
Next, we'll want to adjust our onmessage
event handler to work with these different mutations.
port.onmessage = function({ data }) { switch(data.mutation) { case mutations.ADD: messages = [...messages, data.value]; break; case mutations.SET: messages = data.value; break; } browserInstances.forEach(instance => { instance.postMessage(message); }); }
Copied!
- pages
- scripts
- sharedworker.js
Finally, let's make sure all the data we're emitting from our worker is in the same format, ie:
{ mutation: string, // the mutation that was run value: Array, // the values changes messages: Array // the new messages collection }
Copied!
When we receive an 'ADD' mutation our value is going to be our message object. So, in order to get everything emitting in the same format, we'll want to wrap this value in an array before posting our messages.
if (!Array.isArray(data.value)) { data.value = [data.value]; }
Copied!
- pages
- scripts
- sharedworker.js
Reviewing Our SharedWorker
With that, we are done with our SharedWorker logic. Here's what your /pages/scripts/sharedworker.js
file should look like:
const mutations = { ADD: 'ADD', SET: 'SET' } const browserInstances = []; const messages = []; onconnect = function(e) { const port = e.ports[0]; browserInstances.push(port); port.onmessage = function(event) { switch(data.mutation) { case mutations.ADD: messages = [...messages, data.value]; break; case mutations.SET: messages = data.value; break; } if (!Array.isArray(data.value)) { data.value = [data.value]; } browserInstances.forEach(instance => { instance.postMessage(message); }); } }
Copied!
- pages
- scripts
- sharedworker.js
Binding Our Forms
On our page, we have two forms, one for sending a message and another for clearing all messages. To start out, let's get submit handlers bound to these two forms along with their handler functions. We're also going to have a few global variables to this functionality holding some elements and storage keys.
// pages/sharedworker/index.html const messagesEl = document.getElementById('messages'); const messagesPlaceholderEl = document.getElementById('messagesPlaceholder'); const messagesKey = 'messages'; const senderIdKey = 'senderId'; const mutations = { ADD: 'ADD', SET: 'SET' } const worker = new SharedWorker('scripts/sharedworker.js'); worker.port.onmessage = handleNewMessage document.forms.sendMessageForm.addEventListener('submit', handleSendMessage); document.forms.clearMessagesForm.addEventListener('submit', handleClearMessages); function handleSendMessage(event) { event.preventDefault(); } function handleClearMessages(event) { if (event) event.preventDefault(); } function handleNewMessage({ data }) { }
Copied!
In addition to this, we'll need one more global variable called tabSenderId
, this will be a unique ID we'll use to determine if a message was sent from the tab we're currently on. To start, let's add a utility method to the bottom of our script element to generate a unique ID. Let's also add our tabSenderId
with our other global variables.
// pages/sharedworker/index.html const tabSenderId = getUniqueId(); /***/ function getUniqueId() { return '_' + Math.random().toString(36).substr(2, 9); }
Copied!
Now, we're also going to want to persist the generated tabSenderId
so that when we refresh our browser tabs this remains the same. We'll use SessionStorage for this, which isn't shared across tabs. Let's create a helper function called getSenderId
that will attempt to find a SessionStorage item with the key of our senderIdKey
. If an item is found we'll use that value as our tabSenderId
, otherwise we'll use our getUniqueId
utility function to create a new unique ID that we'll then store in SessionStorage under the key of our senderIdKey
.
// pages/sharedworker/index.html const tabSenderId = getSenderId(); /***/ function getSenderId() { let senderId = sessionStorage.getItem(senderIdKey) || getUniqueId(); sessionStorage.setItem(senderIdKey, senderId); return senderId; } function getUniqueId() { return '_' + Math.random().toString(36).substr(2, 9); }
Copied!
Adding A Few Utilities
Before we go any further, let's add a few utilities that will help keep things concise from here out.
// pages/sharedworker/index.html function hideEl(el) { el.classList.add('hidden'); } function showEl(el) { el.classList.remove('hidden'); } function parseArray(item) { if (!item || !item.length) return []; return !Array.isArray(item) ? JSON.parse(item) : item; } function setStringifiedStorageItem(key, value) { localStorage.setItem(key, value ? JSON.stringify(value) : value); } function getParsedStorageItem(storageKey) { let stringValue = localStorage.getItem(storageKey); return stringValue ? JSON.parse(stringValue) : stringValue; }
Copied!
hideEl
and showEl
are pretty straightforward, but let's run through the rest.
parseArray
will gracefully attempt to parse a stringified array, if neededsetStringifiedStorageItem
will store an item in LocalStorage, stringifying it beforehand.getParsedStorageItem
will get an item from LocalStorage, parsing its contents beforehand.
Getting, Making, and Adding Message Helpers
Now that we have our storage and form listeners set up, we can move into adding some helpers to keep our message logic clear and concise.
Getting Messages
// pages/sharedworker/index.html function getMessages() { const messages = getParsedStorageItem(messagesKey); return !Array.isArray(messages) ? [] : messages; }
Copied!
This will get and return an array of our messages, or an empty array if we have none.
Adding A Message
// pages/sharedworker/index.html function addMessage(message) { let messages = getMessages(); messages.push(message); return messages; }
Copied!
We'll call this when we go to add a message
Making A Message
// pages/sharedworker/index.html function makeMessage(message) { return { id: getUniqueId(), senderId: tabSenderId, message, }; }
Copied!
And this is when we want to make a new message. We'll have a unique ID per message, our tab's sender ID, and the message.
// pages/sharedworker/index.html function makeMessageEl({ message, id, senderId }) { const li = document.createElement('li'); const baseClassName = 'px-3 py-1 rounded'; li.id = id; li.textContent = message; li.className = senderId == tabSenderId ? `${baseClassName} bg-blue-600 text-white place-self-end` : `${baseClassName} bg-gray-700 text-white place-self-start`; return li; }
Copied!
We'll use this to create a message LI element to display our messages. If the message's senderId
equals our tabSenderId
, the message will be blue and aligned right. Otherwise, it'll be gray and aligned left.
Displaying Messages
// pages/sharedworker/index.html function displayMessages(messages) { messages.forEach((msg) => messagesEl.appendChild(makeMessageEl(msg))); }
Copied!
We'll use this to append our created message LI's from makeMessageEl
to the DOM under our messages list.
Post Message
// pages/sharedworker/index.html function postMessage(mutation, value) { worker.port.postMessage({ mutation, value }); }
Copied!
We'll create a helper to send our messages to our SharedWorker. mutation
will be the action we're performing (ADD, SET). 'ADD' will be when we're adding a single message and 'SET' will be used to set our entire message collection.
Clearing All Messages
Good news, we're done with helpers and setup! Let's now add our logic for clearing all our messages.
so, if you recall, earlier we created a handleClearMessages
function that took an event. Let's adjust that method to the one below.
// pages/sharedworker/index.html function handleClearMessages(event) { if (event) event.preventDefault(); postMessage(mutations.SET, []); }
Copied!
To make this function callable from our other methods, we'll check to see if we were given an event. If we were all we want to do is preventDefault
.
Then we'll use our postMessage
utility to clear out our messages collection in our SharedWorker. Our SharedWorker will then post back to use our new messages collection and our handleNewMessage
function will take care of the rest.
Handling New Messages
Now we're ready for our logic to handle new messages sent from the current and other tabs. Let's edit the handleNewMessage
method we added earlier to the below.
// pages/sharedworker/index.html function handleNewMessage({ data }) { const { mutation, value, messages } = data; setStringifiedStorageItem(messagesKey, messages); messages.length ? hideEl(messagesPlaceholderEl) : showEl(messagesPlaceholderEl); switch(mutation) { case mutations.ADD: displayMessages(value); break; case mutations.SET: messagesEl.innerHTML = ''; displayMessages(value); break; } }
Copied!
Here we're spreading out our { mutation, value, messages }
properties from our data object so we can directly reference them.
Next, we update our LocalStorage (used only to persist messages on reload).
Then we determine whether to show or hide our placeholder element. If we have messages we'll hide it, otherwise, we'll show it.
Finally, we'll use a switch to determine which mutation was run and we can individually handle those as needed. When adding all we'll need to do is display the new message. When setting we'll need to clear out our ordered list's innerHTML
before we can display the new messages.
Sending A New Message
Finally, we're ready to add the ability to send a new message. Let's adjust the handleSendMessage
function we added earlier to the below.
// pages/sharedworker/index.html function handleSendMessage(event) { event.preventDefault(); const value = event.target.message.value; const message = makeMessage(value); postMessage(mutations.ADD, message); event.target.reset(); event.target.message.focus(); }
Copied!
We grab the value from the message form input, make a new message matching our { id, senderId, message }
object format. Next, we'll post our new message to our SharedWorker, calling the 'ADD' mutation. Finally, we'll reset our form and refocus the message input.
Let's Test!
We can now finally test what we have! So open up two browser tabs pointing to the /sharedworker
page. If you've had these pages open previously; you'll need to close one tab, refresh the single tab remaining open, then open the second tab back up in order to refresh the SharedWorker. Now, send a message and you should see that message gets added both for the tab you're on and the inactive tab. Huzzah!
Now refresh the page and you'll see why we aren't quite done. Our messages are saved in LocalStorage, but we aren't doing anything to actually populate them on load.
Populating Messages On Load
To do this, let's add one more function to our page called loadMessages
.
// pages/sharedworker/index.html function loadMessages() { const messages = getMessages(); postMessage(mutations.SET, messages); }
Copied!
This gets our messages from LocalStorage, then makes use of our 'SET' mutation to set the entire value of our messages within our worker. Our handleNewMessage
event listener will take care of the rest.
Finally, let's call our loadMessages
function.
// pages/sharedworker/index.html loadMessages();
Copied!
Now refresh the page again and you should see your messages populating on load.
Summary
While we did a lot in this lesson, the actual code needed to communicate across tabs using our SharedWorker was very minimal. All we needed to do was create our worker and add an onmessage
event listener and call postMessage
anytime we have a new message to send.
In the next lesson, we'll be covering how to do the exact same thing using the BroadcastChannel API.
Join The Discussion! (2 Comments)
Please sign in or sign up for free to join in on the dicussion.
Anonymous (ManateeChrystel384)
Beautiful
Please sign in or sign up for free to reply
tomgobich
Thank you!! 😊
Please sign in or sign up for free to reply