How to build an event emitter in JavaScript

RxJS, the stream-based library in JavaScript (JS), popularized the use of the event-based system in building JS apps. Node.js comes with the built-in function EventEmitter, which uses the producer-consumer pattern. The popular JS library SocketIO uses this event-based architecture to implement WebSocket, while other libraries have used it for IOInput-Output or file reading/writing.

Producer-consumer pattern

These libraries are based on the producer-consumer pattern. The producer produces an event and a listening consumer consumes the event.

Producers are the sources of your data. A stream must always have a producer of data. The producer is created from something that independently generates events (anything from a single value, array, or mouse clicks, to a stream of bytes that is read from a file).

Consumers accept events from the producer and process them in a specific way.

Let’s use the events in Node.js for a demonstration:

const EventEmitter = require("events")

Calculator extends EventEmitter {}
const calc = new Calculator()

calc.addEventListener("result", (result)=> log(result))

calc.addEventListener("add", (a,b)=> {
    log("Adding "+ a +" to " + b)
    calc.emit('result', a + b)
})

calc.addEventListener("subtract", (a,b)=> {
    log("Subtracting "+ b +" from " + a)
    calc.emit('result', b - a)
})

calc.emit('add', 1, 2)
calc.emit('subtract', 4, 2)

In the code above, the Calculator class extends the EventEmitter. Then, Calculator registers events [result, add, and subtract] with the addEventListener method, which is inherited from EventEmitter. addEventListener is a consumer, so it listens to the events, and when one is produced addEventListener consumes it.

We create three consumers: add, subtract, and result. The add consumer listens to the ‘add’ event. add will accept the values produced through its function callback parameters a and b. The subtract consumer works similarly. The result consumer waits for the ‘result’ event and will display the result when the result event is produced.

The emit method is inherited from the EventEmitter and serves as the producer. emit produces the events add, subtract, and result, and generates events with data.

The consumers of these events (the callbacks of Calculator#addEventListener) will be called with the data.

Let’s use a real-life scenario to depict the producer-consumer pattern. Obi has a ball and assembles three boys, Ed, Young, and Figo. Then, he tells Ed, “If I say ‘Stop,’ take the ball and perform the action ‘Stop.’” He tells Young if he says “Start,” he should take the ball and perform the action “Start.” For Figo, “Yell” should make him perform the action “Yell.”

After these instructions, the three boys will be listening to Obi for the code word.

Here, Obi is the producer. He will produce the sound codeword with the data, i.e., the ball. Ed, Young, and Figo are the consumers, as they are listening to consume what Obi (the producer) will say.

When Obi says “Yell,” all three of them hear it but the codeword is meant for Figo, so only he will start the action, while Young and Ed keep listening. When Figo is done, he comes back and starts listening again. If Obi says “Run,” nobody will perform an action because the word is not the codeword for any one of them.

The code words are like the events in our code above. The three boys listening to Obi is like Calculator#addEventListeners add, subtract, and result. And Obi saying a codeword is similar to Calculator#emit.

How to build an EventEmitter

First, create a Node project as shown below:

mkdir event-emitter
cd event-emitter
npm init -y
touch events.js

You will have the following code:

- event-emitter
    - package.json
    - events.js

Open events.js to make the edits.

Let’s create an Events class:

class Events {}

We will add a constructor that contains a listeners object literal. This object is where listeners will be stored in a key-value fashion.

class Events {
    constructor() {
        this.listeners = {}
    }
}

Remember, all listeners or observers listen to an event name. The event name will be the key. The listeners have function callbacks that are to be executed when an event name is produced, and the function callback will be the value.

EventEmitter has the on, once, and addEventListener methods.

The on method is used to add a listener. on takes the event name and function callback as arguments.

Let’s implement the method:

class Event {
    constructor() {
        this.listeners = {}
    }
    on(str, fn) {
        this.listeners[str] = fn
    }
}

str holds the event name and fn holds the function callback. We add it to the listeners array, with str as the key and fn as the value.

The once method is used to register an event. The event is removed if the event is emitted and runs once.

class Event {
    constructor() {
        this.listeners = {}
    }
    on(str, fn) {
        this.listeners[str] = fn
    }
    once(str, fn) {
        this.listeners[str] = fn
    }
}

addEventListener is the same as the on method.

class Event {
    constructor() {
        this.listeners = {}
    }
    on(str, fn) {
        this.listeners[str] = fn
    }
    once(str, fn) {
        this.listeners[str] = fn
    }
    addEventListener(str,fn) {
        this.on(str,fn)
    }
}

There is a call to on with its parameters within addEventListener.

Let’s implement the emit method to produce an event with optional data:

class Event {
    constructor() {
        this.listeners = {}
    }
    on(str, fn) {
        this.listeners[str] = fn
    }
    once(str, fn) {
        this.listeners[str] = fn
    }
    addEventListener(str,fn) {
        this.on(str,fn)
    }
    emit(str, data) {
        this.listeners[str](data)
    }
}

emit takes the name of the event to be emitted in the str parameter and the data in the data parameter. emit uses str as a key to get the function callback associated with it in the listeners object, this.listeners[str]. This returns the function callback, which calls this.listeners[str](data) and passes in the data in the data parameter.

However, with the implementation of emit, the once method won’t work. once is supposed to remove an event once it is run.

Let’s fix this:

class Events {
    constructor() {
        this.listeners = {}
    }

    on(str, fn) {
        this.listeners[str] = fn
    }

    emit(str, data) {
        this.listeners[str](data)
    }

    once(str, fn) {
        const self = this
        function onceFn(data) {
            self.removeEventListener(str)
            fn(data)
        }
        this.on(str,onceFn)
    }

    addEventListener(str,fn) {
        this.on(str,fn)
    }

    removeEventListener(str) {
        delete this.listeners[str]
    }
}

We create a function onceFn inside the once method. This creates a closure around the onceFn function. We store the instance to self and call the on method, passing str and the onceFn function that will be the callback. Inside the onceFn function, it uses the self variable to get access to the Events class instance to call its Event#removeEventListener to remove the event.

Next, this calls the original function callback, fn. Note that onceFn is able to access variables outside its scope because of closure. This will effectively make events registered with the once method be emitted and called once.

const events = new Events()
events.once("once",()=>l("once"))
1. events.emit("once")
2. events.emit("once")

The second once emission in the code snippet above will throw an error.

Note: Registering two events with the same name will choose the latter and override the rest.

Testing our implementation

Let’s use our previous Calculator to test our implementation:

const calc = new Events()
calc.addEventListener("result", (result)=> log(result))

calc.addEventListener("add", (a,b)=> {
    log("Adding "+ a +" to " + b)
    calc.emit('result', a + b)
})

calc.addEventListener("subtract", (a,b)=> {
    log("Subtracting "+ a +" from " + b)
    calc.emit('result', a - b)
})

calc.emit('add', 1, 2)
calc.emit('subtract', 4, 2)

Now, let’s run it:

$ node events
Adding 1 to 2
3
Subtracting 4 from 2
2

Sharing with Bit

We can share function utils to Bit. Since they are reusable, they can be shared with other developers, which Bit does with a Node playground for developers to test run the code before they install it.

To begin, we have to install the Bit CLI tool:

$ npm i bit-cli -g

We initialize a Bit environment in our project:

$ bit init

Next, we add the files we want to move to Bit:

$ bit add events
tracking 1 new component

Then, we install the Babel compiler:

$ bit import bit.envs/compilers/babel --compiler
the following component environments were installed
- bit.envs/compilers/babel@0.0.20

This command not only installs the compilers/babel extension but also sets it as a default build step for all components that originate from your workspace. You can see it configured in your package.json file:

{
  "bit": {
    "env": {
        "compiler": "bit.envs/compilers/babel@0.0.20"
    },
    "componentsDefaultDirectory": "components/{name}",
    "packageManager": "npm"
  }
}

We build and tag with version:

$ bit tag --all 0.0.1

Bit tags the component with version 0.0.10.0.1. The component is also built during this tagging process. Bit runs all component extensions during the tagging process to validate that it can recreate all components in an isolated environment and run all tasks, e.g., build and test. Bit fails the versioning process if it is unable to isolate a component.

Before launch, you need to create a collection, which you can do here

With your account name and collection name handy, you can launch:

$ bit export YOUR_ACCOUNT_NAME_HERE.YOUR_COLLECTION_NAME_HERE

After a successful launch, go to your collections page and you will see your component there. You can now play with the event emitter.

Note: You can also view the full code here.