Note: this is first and foremost the tale of a journey. It acts as a how-to, but I also want to share my thought process and how I learnt along the way. If any of the below is total nonsense, let me know in the comments!
Trigger: few months ago I was put in a situation where I was asked to build my own EventEmitter
. I didn't have a clue and it was very embarrassing. The tale below is my quest to learn about it.
I explained it to a friend and he told me: ah, you are supposed to build an [EventTarget](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/EventTarget)!
. Ehhhh, what the hell do you mean?!
I Googled what is js eventemitter
and landed in a nodejs tutorial.
Reading the first few lines of the article made me think about the Javascript Event Loop
, which I have read quite a lot about it.
What is Javascript's Event Loop?
At Lambda School we have a Slack channel where students can share questions they may have been asked in recent job interviews. In my growth mindset attitude and my quest to learn what really matters in the industry, I started to track these questions and read about the topics. Believe or not, the first question I tracked was What is Javascripts Event Loop, and how does it work?
. I had done a bit of research and had settled with the following 2 articles:
The main idea is that Javascript is single threaded. That means that things run one after another and anything that takes time to return blocks the execution of the code. As Flavio ilustrates very well, the event loop continuously checks the call stack
which, like any Stack, it's Last In First Out (LIFO). When it finds a function to execute, it adds it to the Stack
const bar = () => console.log('bar')
const baz = () => console.log('baz')
const foo = () => {
console.log('foo')
bar()
baz()
}
foo()
^ Source: Flavio Copes article
^ Source: Flavio Copes article
What happens when there is async code. Flavio adds a setTimeout()
in his code:
const bar = () => console.log('bar')
const baz = () => console.log('baz')
const foo = () => {
console.log('foo')
setTimeout(bar, 0)
baz()
}
foo()
^ Source: Flavio Copes article
^ Source: Flavio Copes article
In this case, setTimeOut()
, even if triggered after 0 miliseconds, is async. Browser or Node.js start a timer and, when the timer expires, the code that it needs to execute is added to something called the Message Queue
- which sits at the bottom of the Stack. I learnt that user triggered events like mouse clicks are also added to that queue.
ES6 introduced the Job Queue
for Promises
. That means that the result of a Promise (i.e. fetching data from an API) is executed as soon as possible rather than being added to the Message Queue
.
As I read in here, whenever a task gets completed in the Stack, Node fires an event that signals the event-listener to execute. Event handlings is based on the observer pattern
. The observer pattern
is a a software design pattern in which an object, called the subject, maintains a list of its dependents, called observers, and notifies them automatically of any state changes, usually by calling one of their methods.
(source: Wikipedia). The article even has code snippets in JS using... RxJS
. Oh dear, I have heard about RxJS
quite a lot. Putting RxJS in the parking lot for now
Ok man, what about the EventEmitter I was supposed to code?
I went back to the first resource I mentioned. There I read that Many objects in a Node emit events, for example, a net.Server emits an event each time a peer connects to it, an fs.readStream emits an event when the file is opened. All objects which emit events are the instances of events.EventEmitter.
. And it goes on to explain that EventEmitter
is a Class that lives inside the event
module.
I went straight to the example listed in the article to understand the methods. And to my surprise, it all made sense! I coded a little example and I was happy it all made sense. I am not building my own eventEmitter at this point, just practising the methods. The only doubt I had is how to pass arguments to the listeners. For instance:
- I wanted to add the name of the event to the sentence being logged:
const listener1 = (argument) => {
console.log(`Hey, I am listener 1 on event ${argument}`);
};
I thought I would just invoke the listener with an argument in it:
emitter.addListener('test', listener1('test');
That threw an error. I Googled it and found the answer here: it turns out that when adding listener you are only declaring the name of the function, not invoking it. The arguments are passed when the event is emitted. Like so:
emitter.addListener('test', listener1);
emitter.emit('test', 'arg1');
What if we have several listeners that are expecting different arguments? Like so:
const listener1 = (arg1) => {
console.log(`Hey, I am listener 1 on event ${arg1}`);
};
const listener2 = (arg2) => {
console.log(`Hey, I am listener 2 on event ${arg2}`);
return 'listener 2';
};
My understanding from the Stack Overflow answer above is that all arguments possibily expected by any of the listeners have to be passed, and all possible arguments need to be declared in the listener functions. Like so:
const listener1 = (arg1, arg2) => {
console.log(`Hey, I am listener 1 on event ${arg1}`);
};
const listener2 = (arg1, arg2) => {
console.log(`Hey, I am listener 2 on event ${arg2}`);
return 'listener 2';
};
I actually don't need arg2
in listener1
, because it comes after arg1, but I definitely need it in in listener2
otherwise arg2
will be the first argument being passed in the emit
method. Then I am emitting the event test
like so:
emitter.emit('test', 'arg1', 'arg2');
It is actually explained in here but I saw it only later!
Ok. This is what comes out of the the Node.js box. How do you build your own EventEmitter?
This is the destination of my rabbit hole afterall! I Googled it and found the following tutorials:
- How to Create Your Own Event Emitter in JavaScript by Oleh Zaporozhets
- How to code your own event emitter in Node.js: a step-by-step guide by Rajesh Pillai
I had fun reading and finally learning to implement my eventEmitter
. The key concept that I had to grasp was the following:
- An
emitter
emits one or manyevents
. - An
event
triggers one or manylisteners
. Alistener
is a call back function: a function that executes when theevent
is received. But first, you need toadd
orregister
(I think people also call issubscribe
) the listener to the event.
So, conceptually, it makes sense to store the events in an object
inside the emitter
. It also makes sense to store every listener
to an event inside an array
. This way, when an event
is emitted, we look up for the event
inside the object (it is O(1)) and we then execute all the listeners stored in it in sequence (it is O(n)). I suppose that, since all listeners need to be executed, there is no way to improve O(n).
I personally always have a lot of fun working with Classes - I find OOP programming to be super logical and I have fun because everything in it is linked to each other. I know, JS is not pure OOP because it is prototype based... let's leave this for another day.
Now, how do we build the EventEmitter
Class?
I found Rajesh's article great because it builds a lot of the native Node.js eventEmitter
methods (i.e. listenerCount()
, rawListeners()
etc.).
Class
We first build the Class constructor:
class EventEmitter {
constructor() {
this.events = {};
}
}
As discussed, the events
property will be an object and we will access the event listeners with this.events[name]
.
Add Listener
Next, we create the addListener
method. It takes two arguments: name of the event
and listener
(function to be executed when event is emitted):
addListener(name, listener) {
// if event name has not yet been recorded in the object (it is not a property of `this.events` yet), we do it and initialise an array
if (!this.events[name]) {
this.events[name] = [];
}
// we push the `listener` (function) into the array
this.events[name].push(listener);
}
On
since .on
and addListener
are the same, we code .on
like that:
on(name, listener) {
return this.addListener(name, listener);
}
Remove Listener
Next, we can code removeListener()
, which is removing the listener from the array in this.events[name]
:
removeListener(name, listenerToRemove) {
// if event name does not exist in `this.events` object, we throw an error because nothing can be removed
if (!this.events[name]) {
throw new Error(`Can't remove listener, event ${name} doesn't exist`);
}
// we use one of the high order methods (filter) to filter out the listener to be removed from the array
this.events[name] = this.events[name].filter((listener) => {
return listener != listenerToRemove;
});
}
Off
Similar to .on
, .off
is equivalent to removeListener()
. So:
off(name, listenerToRemove) {
return this.removeListener(name, listenerToRemove);
}
Once
Next, I learnt a lot reading how Rajeh implemented the .once
method. once
means that the listener will be automatically removed after it has executed once. So:
once(name, listener) {
// we check if event exists in the object, and if not we create an intialise an array
if (!this.events[name]) {
this.events[name] = [];
}
// we create a wrapper function, which is the one that will be added to the array. This wrapper function executes the listener that we want to add and calls .removeListener
const onceWrapper = (...arg) => {
listener(...arg);
this.removeListener(name, onceWrapper);
};
// we push the wrapper function into the array
this.events[name].push(onceWrapper);
}
The thing that tripped me here is that I was initially removing the listener that I wanted to add. No, I should remove the wrapper because (remember we use the method filter
to remove listeners?) otherwise we won't find it and nothing will be removed. It took me a while to find out what I was doing wrong.
Emit
Next, we code emit
. Emit has an obligatory argument (the name of the event) and then you can pass as many arguments you want to the listeners. That is why I used ...arg
above, since we don't know how many arguments will be passed ahead of time. Maybe a certain listener expects 3 (this number is just an example), and all listeners recorded for the event (added to the array) need to be prepared to receive that many arguments in case their arguments come after these 3. Unless I am wrong, you can achieve this by spreading the arguments (...args
):
emit(name, ...data) {
if (!this.events[name]) {
throw new Error(`Can't emit an event. Event ${name} does not exist.`);
}
this.events[name].forEach((cb) => {
cb(...data);
});
}
First, if event
does not exist (no property with the name
of the event is found inside the this.events
object), then we throw an error. If we find the event, we iterate over the array with forEach
and we execute the listener passing the arguments, if any.
I have seen implementations out there that seem to have forgotten the arguments, or maybe I am missing something. In any case, mine seems to work, if you spot any mistake please let me know in the comments.
Listener count
Next, listenerCount
. It takes one argument (the name of the event) and returns the count of listeners (the ones stored in the array). I think the code is self explanatory:
listenerCount(name) {
if (!this.events[name]) {
this.events[name] = [];
}
return this.events[name].length;
}
Raw Listeners
The last one I coded is rawListeners
, which return an array of the listeners that have been registered to an event. While this had the most misteriours name to me, it's the easiest of all afterall - it just needs to return the array.
rawListeners(name) {
return this.listeners[name];
}
And that is it: you can now instantiate your new EventEmitter
class and run the methods on this instance:
const myEmitter = new EventEmitter();
myEmitter.on('testEvent', handler1);
myEmitter.on('testEvent2', handler1);
myEmitter.emit('testEvent', 'hey there');
myEmitter.emit('testEvent', 'firing event again');
myEmitter.emit('testEvent', 'and again');
etc.
Hope you enjoyed it! If you spot any mistakes, let me know in the comments.