DEV Community

Will C.
Will C.

Posted on

Lessons From Making My Own EventEmitter

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)
  }
}

Enter fullscreen mode Exit fullscreen mode

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'))
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)
    })
  }
Enter fullscreen mode Exit fullscreen mode

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])

Enter fullscreen mode Exit fullscreen mode

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)
    }
  }

Enter fullscreen mode Exit fullscreen mode

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);
  }
Enter fullscreen mode Exit fullscreen mode

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);
    });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)