Adding simple PubSub, part 2
Time to read
, 1260 words, 5th grade
In our previous article we built a simple event delegation system. Our intent was to develop that into a full PubSub system. We can use this PubSub system to create an event-driven architecture.
It will be better if we split the event delegation feature from the PubSub
part so we are clear what weʼre doing. We can begin by creating a listeners
object to replace our subscriptions
object. Weʼll
store our listeners here:
listeners
module.export default {}
We will also rename our subscribe
function to register
. We will move the event listener callback into our register
function:
register
function.import castEvent from "./cast-event.js"
import listeners from "./listeners.js"
export default function (eventType, id, callback) {
const type = castEvent(eventType)
listeners[type] ??= {}
if (!Object.keys(listeners[type]).length) {
document.body.addEventListener(type, function (event) {
const id = event.target?.id
const type = event.type
event.target && listeners?.[type]?.[id]?.(event)
})
}
listeners[type][id] = callback
}
This example shows our register
function that registers listeners
to listen for browser events on the
body
element.
On lines #1 and #2, we import our castEvent
function that we
created in our previous article. This casts focus
to focusin
and blur
to focusout
so we can catch them at
the body
element. We also import our listeners
object that will hold our event listeners for events such as click
, pointerover
, submit
, etc.
Our function (line #4 to #19) takes three parameters and returns void
. The paramters are:
-
The
eventType
for which to listen, such asclick
. - The id of the expected target element.
-
The callback function to call when the
eventType
event is raised on the target element with IDid
. This is passed theevent
object.
First, we cast the eventType
as necessary on line #5.
Then we use nullish coalescing assignment operator (??=
) to
ensure that the key for this type
has an object value on line
#7.
Next, line #9 to #16, we check if this object has any listeners
registered. If not, then we need to add the event listener for this type
to the document body
element. This takes the type
, e.g., click
, and a callback function.
The callback function gets the target id
and the event type
from the event
passed to it, then uses those to find a listener
callback stored in listeners dot type dot id. If it finds one, it calls it
and passes the event along. This happens inside the event listener and is
called on that event.
Now, to finish our register
function, we assign the callback
passed on line #4 to listeners dot type dot id, where, for example, type
might be “click” and id
might be “my-element”.
It should be obvious what this does (see our previous essay). Now letʼs change our unsubscribe
function to an unregister
function:
unregister
function removes listeners.import castEvent from "./cast-event.js"
import listeners from "./listeners.js"
export default function (eventType, id) {
const type = castEvent(eventType)
delete listeners?.[type]?.[id]
if (!Object.keys(listeners[type] || {}).length) {
delete listeners[type]
}
}
In this example, as in the previous one above, we begin by importing the
castEvent
function (line #1) and the
listeners
shared object (line #2).
Then we create our unregister
function on lines #4 to #12. This
function takes two parameters: the eventType
and the target
id
, as did the register
function.
We cast the event type using castEvent
on line #5. So focus
becomes focusin
and blur
becomes focusout
. We cast these in our register
function to allow us to catch
these events after bubbling up to the body
element. blur
and focus
do not bubble up.
On line #7, we delete the key-value pair for that ID in the listeners
object for that type
. Then on lines #9 to #11, we check if
that type
object is empty and if so, delete it as well.
Again, this works exactly the same as our previous unsubscribe
function. Now we have moved our event delegation system into itʼs
own module. Now to our PubSub system.
Note that after we unregister the last listener, we clean up after ourselves.
As a reminder, here is our castEvent
function code:
focus
and blur
to focusin
and focusout
.export default function (type) {
if (type === "blur") {
return "focusout"
}
if (type === "focus") {
return "focusin"
}
return type
}
This example shows the castEvent
function exported as a default
anonymous function. It takes one parameter: the event type.
It contains two conditionals. The first checks if the event type is “blur”; if it is, then it returns “focusout” instead. The second does the same for “focus”, returning “focusin” instead.
If neither conditional is true, then the function returns the type unaltered.
Adding the PubSub system
We copied the code above into new files. This left our previous pubsub
files. So, we already have our basic PubSub module. But we will need to
update them a bit.
The subscriptions
shared object remains as before:
subscriptions
module.export default {}
As before, this example is as simple as it gets: export default empty object literal.
Our unsubscribe
function is also as before. But as we are no
longer dealing with browser events, we donʼt need the castEvent
function anymore. Those browser events go to our event delegator system
above, not to PubSub.
We have no ID, but weʼll want a way to unsubscribe, so we generate
a
UUID and return that from our subscribe
function.
subscribe
function.import subscriptions from "./subscriptions.js"
export default function (topic, callback) {
subscriptions[topic] ??= {}
if (Object.keys(subscriptions[topic]) < 1) {
document.body.addEventListener(
topic,
(event) => {
for (const cb of Object.values(subscriptions[topic])) {
cb(event)
}
},
true,
)
}
const token = crypto.randomUUID()
subscriptions[topic][token] = callback
return token
}
This JavaScript example imports our subscriptions
object on
line #1. Lines #3 to #19 export the anonymous subscribe
function
as default.
Our function takes a topic and a callback function. On line #4 we use the nullish coalescing assignment operator to assign an empty object to this topic if one does not already exist.
Then on lines #6 to #12, we check if this topicʼs object is empty. If it is, then we need to add an event listener for the topic. The callback we pass to the listener loops through all the callback functions for this topic. It calls each in turn and passes it the event.
Then we use the crypto
moduleʼs randomUUID
function to generate a UUID as our token. We assign the callback to that token as key in our subscriptions
object for this topic, then we return the token to be used to unsubscribe.
This is pretty self-explanatory, I hope. Our subscribe
function takes two parameters:
-
the event
type
to listen for, e.g.,EMAIL_UPDATED
-
the
callback
function to call when some component publishes an event of this type
We will decide which events to publish and to subscribe to. The nice thing about a PubSub system is that we can create any event we like. We are not limited to browser events.
First, we ensure that there is an object associated with this subscription type (line #4).
If this is the first subscription of this type
, then we add
a listener for this event type (lines #6 to #12). Our callback loops
through all the individual callbacks stored in the subscriptions
object. It calls each in turn, passing the current event
object.
Then we generate a random UUID as our token
. We use this to assign the passed-in
callback to this topic and token (line #16). Finally, we return the
token for use by the unsubscribe
function (line #18).
What about our unsubscribe
function? It is simple as can be:
unsubscribe
function.import subscriptions from "./subscriptions.js"
export default function (topic, token) {
delete subscriptions?.[topic]?.[token]
if (Object.keys(subscriptions[topic]) < 1) {
delete subscriptions[topic]
}
}
This JavaScript example of the unsubscribe
function begins
by importing the subscriptions object. Then, on lines #3 to #9, it exports
as default an anonymous function that takes a topic and a token.
Inside the function it uses the delete
operator to delete
the key-value pair for this topic and token. Then, if the object for this
topic has no remaining callbacks, it deletes the object as well.
We take as parameters the topic
and the token
.
Then we delete the callback
for that topic/token in the subscriptions
object.
Finally, if there are no remaining subscribers for that topic, we delete
the topic
.
Now we need a publish
function to allow us to publish to custom
events. Here it is:
publish
function.export default function (topic, detail = {}) {
const customEvent = new CustomEvent(topic, {
bubbles: false,
detail,
})
document.body.dispatchEvent(customEvent)
}
Our example publish
function is quite simple. We export an
anonymous function as default. It takes a topic and a detail object.
Inside the function, we create a new CustomEvent
object, passing
it the topic
and an options
object. The options
object sets bubbles
to false
, and includes
our details object.
Then, at the bottom of the function, we call dispatchEvent
on the body and pass it our CustomEvent
object. Itʼs that simple.
We pass the topic
, which is our custom event such as EMAIL_UPDATED
. And we pass a detail
object. This is the data that our custom
event will carry. It could be anything. See our examples below.
On lines #2 to #8, we create a CustomEvent
. We assign it
our topic
as the event type. This is what our subscribers are
listening for. We also pass the detail
object and set event
bubbling to false
.
Then on line #10 we call dispatchEvent
on the document.body
element to dispatch our custom event. By dispatching it and
listening for it on the body
element, we do not need to bubble
it.
Our PubSub system in action
We add the PubSub system to our window (globalThis
) object.
We add a single object representing our namespace, _xx
.
Then we add our various modules to that:
globalThis
.import listeners from "./modules/listeners.js"
import publish from "./modules/publish.js"
import register from "./modules/register.js"
import subscribe from "./modules/subscribe.js"
import subscriptions from "./modules/subscriptions.js"
import unregister from "./modules/unregister.js"
import unsubscribe from "./modules/unsubscribe.js"
globalThis._xx ??= {}
Object.assign(globalThis._xx, {
listeners,
publish,
register,
subscribe,
subscriptions,
unregister,
unsubscribe,
})
console.info("« PubSub and event delegation enabled. »")
In this example of our index.js
module, we begin by importing
our modules:
- listeners
- publish
- register
- subscribe
- subscriptions
- unregister
- unsubscribe
Then we use the nullish coalescing assignment operator to assign an
empty object literal to globalThis._xx
. Finally, we use Object.assign
to add the above modules to this object. This makes them available globally
behind the namespace “_xx”.
We end the file by logging “PubSub and event delegation enabled” to the console.
Weʼve set up an example of the PubSub system in use. You can see the various modules there:
- /modules/cast-event.js
- /modules/listeners.js
- /modules/publish.js
- /modules/register.js
- /modules/subscribe.js
- /modules/subscriptions.js
- /modules/unregister.js
- /modules/unsubscribe.js
- index.js
Now letʼs set up a simple test page. Imagine that we have a set of individually-editable fields, such as name, email, and phone:
<form id="name-editor">
<label for="name">Name</label>
<input
id="name"
name="name"
type="text"
/>
<button type="submit">Update</button>
</form>
<form id="email-editor">
<label for="email">Email</label>
<input
id="email"
name="email"
type="email"
/>
<button type="submit">Update</button>
</form>
<form id="phone-editor">
<label for="phone">Phone</label>
<input
id="phone"
name="phone"
type="tel"
/>
<button type="submit">Update</button>
</form>
This simple HTML example shows three nearly identical forms. Each
contains a label, an input
element, and a button
. The first form is for the name, the second for an email address,
and the third for a phone number.
We will use these to trigger custom events on submit and publish them to our PubSub system.
And we will include some temporary elements to permit us to display our events:
<pre id="name-output"></pre>
<pre id="email-output"></pre>
<pre id="phone-output"></pre>
This example shows three pre
elements. These are for demonstration
only. We will post our custom events here, stringified.
Now in our script we first import the PubSub module and then register
our event listeners for submit
:
<script
src="./index.js"
type="module"
></script>
<script type="module">
globalThis.addEventListener("DOMContentLoaded", async () => {
globalThis._xx?.register("submit", "name-editor", (event) => {
event.preventDefault()
globalThis._xx?.publish("NAME_UPDATED", {
id: event.target.elements.name.id,
name: event.target.elements.name.value,
})
})
// register submit listeners for email and phone, too
})
</script>
In this example, we show two script
elements. The first has
src
set to “./index.js” and type
set to “module”. The second contains our script, and has
type
“module” as well.
In our second script element, we add an event listener to globalThis
using the addEventListener
method. We pass it the “DOMContentLoaded” event and an anonymous arrow function as the callback
(listener).
In the callback, we call register
from our globalThis._xx
module and pass it three arguments:
- The event we are registering, in this instance, “submit”
-
The
id
of the element that will raise this event, here “name-editor” - A callback function to be called on submission of the form
In the callback function, we first call event.preventDefault()
to stop submission of the form. We will publish an event instead.
Then we call the publish
function from the globalThis._xx
object, passing it two arguments. The first is the topic, here “NAME_UPDATED”. The second is our detail object. Here we give it
two key-value pairs: the id
of the input and the value
of the form input.
Not shown here, we do the same for the email and phone mini forms.
On line #1, we import our PubSub system as a module. Then, on lines #3
to #20, we add a DOMContentLoaded
event listener to run our
registration code after the DOM loads.
Lines #4 to #15 use the register
function of our event delegation
system to register a submit
handler. This listens for a submit
event on the form with the passed ID of “name-editor”. On
submit, it calls the passed callback, which:
- prevents the default submission (line # 8) so we donʼt reload the page
-
calls the
publish
PubSub function to publish our custom event. We will give it the typeNAME_UPDATED
. And weʼll pass adetails
object with theid
andvalue
of the input.
We do the same thing for the email
and phone
fields. Now, when we submit any of these mini-forms, PubSub will create a custom event and publish it to our PubSub system.
We can now subscribe to these events anywhere in our app. And when a component publishes that event, then we can do nothing, one thing, or many things.
Our forms donʼt know who is listening to their events. Our subscribers donʼt know (or care) who is raising these events or why. We have decoupled our code. The PubSub system acts as an event bus to pass around these events. And we can make up any events we need.
Now, to show how it works, we will add subscribers. These will post the
stringified events to the pre
elements we added above:
<script type="module">
globalThis.addEventListener("DOMContentLoaded", async () => {
// register event listeners that publish custom events
const first = globalThis._xx?.subscribe("NAME_UPDATED", (event) => {
const pre = document.querySelector("pre#name-output")
pre.appendChild(
document.createTextNode(
JSON.stringify(
{
type: event.type,
detail: event.detail,
},
null,
2,
) + "\n\n",
),
)
})
// subscribe to EMAIL_UPDATED and PHONE_UPDATED events as well
})
</script>
In this example, we add subscribers to our page. These will listen for our three custom events. Those would be the events published by the three mini forms upon submit.
We use the subscribe
function in the globalThis._xx
object. We call it with the topic, e.g., “NAME_UPDATED”,
and a callback function. In the callback function we use querySelector
to snag the appropriate pre
element. Then we create a text
node with the stringified event type and details object and append it to
the pre
element.
Not shown, we do the same for the “EMAIL_UPDATED” and “PHONE_UPDATED” custom events.
Here we call the subscribe
function of our PubSub module. We
pass it the topic
to which we wish to subscribe. And we pass
a callback function to call when a component raises that topic.
In our example callback function, we grab the pre
element for
that event. Then we stringify the event detail and append it as a child to
that element.
You can try this yourself on our example test page.
Of course, this is a simple example. We can do much more with this. For example, we can include much more detail about the event. We can also have more generic or more specific events. We could also:
- Allow handlers to delete themselves after a specific number of calls (e.g., once)
- Broadcast an event to all topics
-
Use
BroadcastChannel
to pass events between browser tabs or windows -
Use
websockets
(or similar) to pass events between different devices
In part 3 of this series, weʼll extend the system a bit. Stay tuned.