I recently completed a pramp practice problem that I found very enjoyable. It involved creating your own event emitter class in Javascript with the methods on
, off
, emit
, and once
.
on
subscribes a call back to an event name.
off
removes a callback from an event name.
emit
calls all the callbacks associated with an event name and any arguments passed to emit.
once
is similar to on
, with the added logic of unsubscribing itself after being called once.
Creating the class and Subscribing
Let's start off by making the class and implementing the on
method.
class EventEmitter {
constructor(){
this.events = {}
}
on(name, cb) {
if (!this.events[name]) {
this.events[name] = []
}
this.events[name].push(cb)
}
}
Upon instantiation of an EventEmmitter
class, an internal state housing all the event names is created. The on
method takes a name
string and cb
function. The method will then add the cb
to an array keyed to the event name. If no previous callbacks were added to this event name, a new key is created.
An example of this method in action:
const emitter = new EventEmitter()
emitter.on('click', () => console.log('I got clicked'))
Emitting and Unsubscribing
Now let's extend the EventEmitter class with some more functionality.
The actual emitting of an event can be done in a for loop, iterating through the cb
's stored to an event name. In this example, I am using the ES6 spread (...
) to store all the arguments passed to the emit
and passed them to the callbacks within the loop.
// within EventEmitter class
emit(name, ...args) {
if (!this.events[name]) {
// Ignore event names we don't have callbacks for.
return;
}
for (let cb of this.events[name]) {
cb(...args);
}
}
// usage
emitter.emit("click")
Next let's unsubscribe a callback from an event name. Using this simple implementation, the only way to unsubscribe an event is by keeping a reference to the callback you made. We will need it for comparing the callbacks within the callback array. Later on in the blog post I'll talk about another method of unsubscribing.
// within EventEmitter class
off(name, cb) {
if (!this.events[name]) {
return;
}
this.events[name] = this.events[name]
.filter(callback => callback !==
cb);
}
// usage
const logClicks = () => console.log('I got clicked')
emitter.on('click', logClicks)
emitter.emit('click') // I got clicked!
emitter.off('click, logClicks)
emitter.emit('click') // Nothing happens.
Interesting Part
The final method, once
, is where things get interesting. The imperative approach could be keeping a some additional internal state for once
callbacks, and performing a check every time we run emit to see if the callback exists in the once state.
There is a much more elegant way of removing the once
callback by leveraging javascript's first class treatment of functions.
Instead of storing more state, I can wrap the passed in callback with another function, and add some additional logic to it to remove itself after it gets called. This is what it would look like:
once(name, cb) {
this.on(name, function onceCB(...args) {
cb(...args)
this.off(name, onceCB)
})
}
Trying to run this code alone will not work though. this
inside of onceCB
is undefined
! What do we do???
Context in Javascript
Context in javascript is a confusing topic that trips people up all the time. This is where some lesser known javascript APIs and arrow functions come in. Objects in Javascript have 3 methods that can be used for defining a this
context. They include bind
, call
, and apply
.
bind
may be familiar with those who have some React experience. You would typically see a bind for methods that get passed as event handler functions. These methods need a bind to the component class they belong to because without it, the function would automatically bind to its nearest context where it gets called. In our case above, the function is being called in the global scope which is undefined.
call
and apply
are similar with a small difference. Both are used for invoking a function. Both take a context as its first parameter. call
takes arguments individually, while apply
takes an array of arguments. Either can be used interchangeably depending on your coding style or the coding styles defined by your project.
someFunc(a, b)
someFunc.call(this, a, b)
someFunc.apply(this, [a,b])
Arrow functions, introduced in ES2015 (ES6) do a bit of magic behind the scenes, and automatically bind functions to the context where they are defined. This simplifies functions for developers as you usually want your functions to use the context where they were defined, reducing the overhead of remembering to bind.
Now that we know a bit more about how context works in javascript, let's look at some ways we can fix the once
method above:
Using call or apply:
// No need to modify the `once` method.
emit(name, ...args) {
if (!this.events[name]) {
// Ignore event names we don't have callbacks for.
return;
}
for (let cb of this.events[name]) {
- cb(...args);
+ cb.apply(this, args); // or cb.call(this, ...args)
}
}
Using arrow functions:
// No need to modify the `emit` method
once (name, cb) {
- this.on(name, function onceCB(...args) {
- cb(...args)
- this.off(name, onceCB)
- })
+ const wrappedCB = (...args) => {
+ this.off(name, wrappedCB);
+ cb(...args);
+ };
+ this.on(name, wrappedCB);
}
I tried to use bind
in a similar fashion as the arrow function method but I was still getting the TypeError: Cannot read property 'off' of undefined
error. I was able to get the once method to work without having to use apply or call in emit by storing a reference to this and using it in side of the wrappedCB
once (name, cb) {
+ const self = this;
this.on(name, function singleCB(...args) {
- this.off(name, singleCB);
+ self.off(name, singleCB);
cb(...args);
});
Bonus round, a nicer Unsubscribe API
Having to store your callback for the sole purpose of unsubscribing is not the nicest API. You may prefer to just write the callback inline with the on
call. The pattern I am about to show you is used in popular libraries like the Firebase Web client and jsdom to handle unsubscribing or cleaning up an instance.
Inside of the on
method. instead of returning nothing, it can return a function which can call the off method for us.
// At the end of `on`
// using self
const self = this;
function cleanup() {
self.off(name, cb);
}
return cleanup;
// or using arrow
return () => {
this.off(name, cb);
};
// usage
const jelly = emitter.on('jelly', function(...args) console.log('jelly time', ...args))
emitter.emit('jelly', '1', '2', '3') // jelly 1 2 3
jelly() // unsubscribe the subscription
emitter.emit('jelly', '1', '2', '3') // nothing happens
Summary
Creating your own event emitter was a fun exercise. I got to practice the subscriber pattern in javascript which is typically abstracted away from me.
I got to see the motivation behind arrow functions and how they greatly simplify writing javascript applications.
Lastly, I got to use the apply
and call
methods for the first time! I typically focus on writing application logic so this change of scenery gave some great insight into what more advanced javascript looks like and helped me gain a better grasp of how this
works.
If you made it this far I hope you have learned something new today and try this out on your own.
Until next time...
Here is the final working class
class EventEmitter {
constructor() {
this.events = {};
}
on(name, cb) {
if (!this.events[name]) {
this.events[name] = [];
}
this.events[name].push(cb);
// using self
// const self = this;
// function cleanup() {
// self.off(name, cb);
// }
// return cleanup;
// using arrow
return () => {
this.off(name, cb);
};
}
once(name, cb) {
// Using arrow:
const wrappedCB = (...args) => {
this.off(name, wrappedCB);
cb(...args);
};
this.on(name, wrappedCB);
// Using self object:
// const self = this;
// this.on(name, function wrappedCB(...args) {
// self.off(name, wrappedCB);
// cb(...args);
// });
// Original
// this.on(name, function singleCB(...args) {
// this.off(name, singleCB);
// cb(...args);
// });
}
emit(name, ...args) {
if (!this.events[name]) {
return;
}
for (let cb of this.events[name]) {
cb(...args);
// If not using arrow or self inside of `once`
// cb.apply(this, args);
// cb.call(this, ...args);
}
}
off(name, cb) {
if (!this.events[name]) {
return;
}
this.events[name] = this.events[name].filter(callback => callback !== cb);
}
}
Top comments (0)