DEV Community

Safia Abdalla
Safia Abdalla

Posted on

Node module deep-dive: EventEmitter

So, I got pretty in the weeds with some C++ code in my last Node-related blog post and I figured I would get back into my comfort zone with some more JavaScript reading.

When I first started to learn Node, once of the things that I had trouble grasping was the event-driven nature of the language. I hadn’t really interacted much with event-driven programming languages. Well, in hindsight, I guess I have. Prior to learning Node, I had used jQuery’s .on and .click in my code, which is an event-driven programming style. At that point, it hadn’t really hit me that I was writing event-driven code. Anyways, one of the things I’ve been curious to dive into for a while now is the event emitter in Node. So let’s do that.

If you aren’t familiar with Node’s event-driven nature, there are a couple of blog posts you can check out that explain it much better than I can. Here’s a few that might be helpful to you.

OK! So I wanna read the code for the EventEmitter and see if I can grok what’s going under the hood with the EventEmitter class. You can find the code that I am going to be referencing here.

So the two most critical functions in any EventEmitter object are the .on function and the .emit function. The .on function is the function that is responsible for listening to an event of a particular type. The .emit function is responsible for dispatching events of a particular type. I decided to start my exploration by diving into the code for these particular functions. I’m gonna start with .emit since it makes sense to see how events are emitted before looking at how they are listened to.

So the function declaration for emit is pretty self-explanatory if you’ve worked with EventEmitter objects. It takes in a type argument, which is usually a string, and a set of arguments that will be passed to the handler.

EventEmitter.prototype.emit = function emit(type, ...args) {

Enter fullscreen mode Exit fullscreen mode

The first thing I noticed in this particular code is that “error”-type events and events of other types are handled differently. To be honest, it took me a while to grok what was happening exactly in the code below, especially the little if-else if bit. So basically, what this bit of code does is check to see if the event that is being emitted is an error. If it is, it checks to see if there is a listener for error events in the set of listeners attached to the EventEmitter. If there is a listener attached, the function returns

let doError = (type === 'error');

const events = this._events;
if (events !== undefined)
  doError = (doError && events.error === undefined);
else if (!doError)
  return false;

Enter fullscreen mode Exit fullscreen mode

If there is no event listener (as the comment states), then the emitter will throw an error to the user.

// If there is no 'error' event listener then throw.
if (doError) {
  let er;
  if (args.length > 0)
    er = args[0];
  if (er instanceof Error) {
    throw er; // Unhandled 'error' event
  }
  // At least give some kind of context to the user
  const errors = lazyErrors();
  const err = new errors.Error('ERR_UNHANDLED_ERROR', er);
  err.context = er;
  throw err;
}

Enter fullscreen mode Exit fullscreen mode

On the other hand, if the type that is being thrown is not an error, then the emit function will look through the listeners attached on the EventEmitter object to see if any listeners have been declared for that particular type and invoke them.

const handler = events[type];

if (handler === undefined)
  return false;

if (typeof handler === 'function') {
  Reflect.apply(handler, this, args);
} else {
  const len = handler.length;
  const listeners = arrayClone(handler, len);
  for (var i = 0; i < len; ++i)
    Reflect.apply(listeners[i], this, args);
}

return true;

Enter fullscreen mode Exit fullscreen mode

Neat-o! That was pretty straightforward. On to the on function…

The on function in the EventEmitter implicitly invokes the _addListener internal function which is defined with a declaration as follows.

function _addListener(target, type, listener, prepend)

Enter fullscreen mode Exit fullscreen mode

Most of these parameters are self-explanatory, the only curious one for me was the prepend parameter. As it turns out, this parameter defaults to false and is not configurable by the developer through any public APIs.

Side note: Just kidding! I came across some GitHub commit messages that cleared this up. It appears that it is set to false in the _addListener object because a lot of developers were inappropriately accessing the internal _events attribute on the EventEmitter object to add listeners to the beginning of the list. If you want to do this, you should use prependListener.

The _addListener function starts off by doing some basic parameter-validation. We don’t want anyone to shoot themselves in the foot! Once the parameters have been added, the function attempts to add the listener for type to the events attribute on the current EventEmitter object. One of the bits of code that I found interesting was the code below.

if (events === undefined) {
  events = target._events = Object.create(null);
  target._eventsCount = 0;
} else {
  // To avoid recursion in the case that type === "newListener"! Before
  // adding it to the listeners, first emit "newListener".
  if (events.newListener !== undefined) {
    target.emit('newListener', type,
                listener.listener ? listener.listener : listener);

    // Re-assign `events` because a newListener handler could have caused the
    // this._events to be assigned to a new object
    events = target._events;
  }
  existing = events[type];
}

Enter fullscreen mode Exit fullscreen mode

I’m particularly curious about the else here. So it looks like if the events attribute has already been initialized on the current EventEmitter object (meaning that we’ve already added a listener before), there is some funky edge-case checking business going on. I decided to do some GitHub anthropology to figure out when this particular code change had been added to get some more context into how the bug emerged and why it was added. I quickly realized this was a bad idea because this particular bit of logic has been in the code for about 4 years and I had trouble tracking down when it originated. I tried to read the code more closely to see what type of edge case this was checking for exactly.

I eventually figured it out not by reading code, but by reading documentation. Don’t forget to eat your vegetables and read all the docs, kids! The Node documentation states:

All EventEmitters emit the event 'newListener' when new listeners are added and 'removeListener' when existing listeners are removed.

So basically, the newListener event is emitted when a new listener is added before the actually listener is added to the _events attribute on the EventEmitter. This is the case because if you are adding a newListener event listener and it is added to the list of events before newListener is emitted by default then it will end up invoking itself. This is why this newListener emit code is placed at the top of the function.

The next bit of code tries to figure out whether a listener for this type has already been attached. Basically, what this is doing is making sure that if there is only one listener for an event then it is set as a function value in the _events associative array. If they are more than one listeners, it is set as an array. It is a minor optimizations, but many minor optimizations are what make Node great!

if (existing === undefined) {
  // Optimize the case of one listener. Don't need the extra array object.
  existing = events[type] = listener;
  ++target._eventsCount;
} else {
  if (typeof existing === 'function') {
    // Adding the second element, need to change to array.
    existing = events[type] =
      prepend ? [listener, existing] : [existing, listener];
    // If we've already got an array, just append.
  } else if (prepend) {
    existing.unshift(listener);
  } else {
    existing.push(listener);
  }

Enter fullscreen mode Exit fullscreen mode

The last check made in this function tries to confirm whether or not there were too many listeners attached on a particular event emitter for a particular event type. If this is the case, it might mean that there is an error in the code. In general, I don’t think it is good practice to have many listeners attached to a single event so Node does some helpful checking to warn you if you are doing this.

  // Check for listener leak
  if (!existing.warned) {
    m = $getMaxListeners(target);
    if (m && m > 0 && existing.length > m) {
      existing.warned = true;
      // No error code for this since it is a Warning
      const w = new Error('Possible EventEmitter memory leak detected. ' +
                          `${existing.length} ${String(type)} listeners ` +
                          'added. Use emitter.setMaxListeners() to ' +
                          'increase limit');
      w.name = 'MaxListenersExceededWarning';
      w.emitter = target;
      w.type = type;
      w.count = existing.length;
      process.emitWarning(w);
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

And that’s it! At the end of all this, this .on function returns the EventEmitter object it is attached to.

I really liked reading the code for the EventEmitter. I found that it was very clear and approachable (unlike the C++ adventure I went on last time) — although I suspect this has to do a fair bit with my familiarity with the language.

Top comments (1)

Collapse
 
fly profile image
joon

This was exactly the level of depth, expertise and hand-holding that I was hoping for when I initially searched for "how do event emitters work under the hood" on google.
Thank you for writing this post. It truly feels like a god-send :)