In the observer pattern (also commonly known as the publish-subscribe model), we can observe/subscribe to events emitted by publishers and execute code whenever an event happens.
Implement an EventEmitter class similar to the one in Node.js that follows such an observer pattern.
Example usage of the EventEmitter class:
const emitter = new EventEmitter();function addTwoNumbers(a, b) {console.log(`The sum is ${a + b}`);}emitter.on('foo', addTwoNumbers);emitter.emit('foo', 2, 5);// > "The sum is 7"emitter.on('foo', (a, b) => console.log(`The product is ${a * b}`));emitter.emit('foo', 4, 5);// > "The sum is 9"// > "The product is 20"emitter.off('foo', addTwoNumbers);emitter.emit('foo', -3, 9);// > "The product is -27"
Implement the following APIs:
new EventEmitter()Creates an instance of the EventEmitter class. Events and listeners are isolated within the EventEmitter instances they're added to, aka listeners shouldn't react to events emitted by other EventEmitter instances.
emitter.on(eventName, listener)Adds a callback function (listener) that will be invoked when an event with the name eventName is emitted.
| Parameter | Type | Description |
|---|---|---|
eventName | string | The name of the event. |
listener | Function | The callback function to be invoked when the event occurs. |
Returns the EventEmitter instance so that calls can be chained.
emitter.off(eventName, listener)Removes the specified listener from the list of listeners for the event with the name eventName.
| Parameter | Type | Description |
|---|---|---|
eventName | string | The name of the event. |
listener | Function | Callback function to be removed from the list of listeners for the event. |
Returns the EventEmitter instance so that calls can be chained.
emitter.emit(eventName[, ...args])Invokes each of the listeners listening to eventName with the supplied arguments in order.
| Parameter | Type | Description |
|---|---|---|
eventName | string | The name of the event. |
...args | any | Arguments to invoke the list of listener functions with. |
Returns true if the event had listeners, false otherwise.
An event-based interaction model is the most common way of building user interfaces. The DOM is also built around this model with the document.addEventListener() and document.removeEventListener() APIs to allow responding to events like click, hover, input, etc.
The following are good questions to ask the interviewer to demonstrate your thoughtfulness. Depending on their response, you might need to adjust the implementation accordingly.
emitter.emit() be called without any arguments besides the eventName?
eventName?
eventName is emitted in the order they were added.emitter.off() is being called once for that listener?
this value of the listeners be?
null.emitter.emit()?
We will handle all the above cases except for the last two cases.
Firstly, we have to decide on the data structure to store the events and the listeners. We can either use:
eventNames to an array of listener functions.events = {foo: [Function1, Function3],bar: [Function2],};
eventName.eventName is provided by the user, it can be any value and might conflict with existing keys on Object.prototype such as toString. We will handle this situation.eventName and listener pairs.events = [{ eventName: 'foo', listener: Function1 },{ eventName: 'bar', listener: Function2 },{ eventName: 'foo', listener: Function3 },];
emit() and off() operations will require iterating through the array, you can't instantly determine if an event exists and ignore emission of non-existent events.eventName strings.Approach #1 is clearly superior, so we will use that. To mitigate the issue of user-provided eventNames conflicting with keys on Object.prototype, we can instantiate the _events object with Object.create(null) or use a ES6 Map class.
EventEmitter.on()Implementing EventEmitter.on() is pretty straightforward. Firstly check if eventName is present as a key of the _events object and make the value an empty array (for the list of listeners for that event) if it is the first time this eventName is encountered. Then push the listener into the array.
Return this so that the method can be chained.
EventEmitter.off()First check if eventName is present as a key of the _events object. If no events with eventName exists, we do not need to proceed further and can do an early return.
Since we only want to remove the first instance of any matching listener, we'll use listeners.findIndex() and remove only one instance via .splice(), instead of using something like .filter() which will remove all matching instances.
Return this so that the method can be chained.
EventEmitter.emit()Check if the eventName exists or has any events and we can terminate and return false if the eventName doesn't exist or if there are no listeners for eventName.
To pass the rest of the arguments to each listener, we have to use ...args in the method signature to capture all other arguments as the variable args. The listeners can be called with args via Function.prototype.apply() or Function.prototype.call().
eventNamesAs mentioned above, if you're using a plain JavaScript object to map eventName to callbacks, one potential issue is using eventNames that clash with properties existing on JavaScript objects such as valueOf and toString.
const emitter = new EventEmitter();emitter.emit('toString'); // Might crash because the property does exist on the object.
Two ways to handle this:
Map instead of an object. This is the modern approach.Object.create(null) so that the object does not have a prototype and no additional properties.export default class EventEmitter {constructor() {// Avoid creating objects via `{}` to exclude unwanted properties// on the prototype (such as `.toString`).this._events = Object.create(null);}on(eventName, listener) {if (!Object.hasOwn(this._events, eventName)) {this._events[eventName] = [];}this._events[eventName].push(listener);return this;}off(eventName, listener) {// Ignore non-existing eventNames.if (!Object.hasOwn(this._events, eventName)) {return this;}const listeners = this._events[eventName];// Find only first instance of the listener.const index = listeners.findIndex((listenerItem) => listenerItem === listener,);if (index < 0) {return this;}this._events[eventName].splice(index, 1);return this;}emit(eventName, ...args) {// Return false for non-existing eventNames or events without listeners.if (!Object.hasOwn(this._events, eventName) ||this._events[eventName].length === 0) {return false;}// Make a clone of the listeners in case one of the listeners// mutates this listener array.const listeners = this._events[eventName].slice();listeners.forEach((listener) => {listener.apply(null, args);});return true;}}
export default function EventEmitter() {// Avoid creating objects via `{}` to exclude unwanted properties// on the prototype (such as `.toString`).this._events = Object.create(null);}/*** @param {string} eventName* @param {Function} listener* @returns {EventEmitter}*/EventEmitter.prototype.on = function (eventName, listener) {if (!Object.hasOwn(this._events, eventName)) {this._events[eventName] = [];}this._events[eventName].push(listener);return this;};/*** @param {string} eventName* @param {Function} listener* @returns {EventEmitter}*/EventEmitter.prototype.off = function (eventName, listener) {// Ignore non-existing eventNames.if (!Object.hasOwn(this._events, eventName)) {return this;}const listeners = this._events[eventName];// Find only first instance of the listener.const index = listeners.findIndex((listenerItem) => listenerItem === listener,);if (index < 0) {return this;}this._events[eventName].splice(index, 1);return this;};/*** @param {string} eventName* @param {...any} args* @returns {boolean}*/EventEmitter.prototype.emit = function (eventName, ...args) {// Return false for non-existing eventNames or events without listeners.if (!Object.hasOwn(this._events, eventName) ||this._events[eventName].length === 0) {return false;}// Make a clone of the listeners in case one of the listeners// mutates this listener array.const listeners = this._events[eventName].slice();listeners.forEach((listener) => {listener.apply(null, args);});return true;};
emitter.emit() is called without any arguments.eventNames.eventNames are built-in object properties like valueOf, toString.this context, hence they should not be used as methods on objects as the this will not be referring to the object. Thus the emitter.on() and emitter.off() methods cannot be defined as an arrow function if the return value is the this object.EventEmitter's implementation allows eventNames to be symbols which we don't allow here.console.log() statements will appear here.