Day 28: Event Emitter
Design an EventEmitter class. This interface is similar (but with some differences) to the one found in Node.js or the Event Target interface of the DOM. The EventEmitter should allow for subscribing to events and emitting them.
Your EventEmitter class should have the following two methods:
- subscribe - This method takes in two arguments: the name of an event as a string and a callback function. This callback function will later be called when the event is emitted.
An event should be able to have multiple listeners for the same event. When emitting an event with multiple callbacks, each should be called in the order in which they were subscribed. An array of results should be returned. You can assume no callbacks passed tosubscribeare referentially identical.
Thesubscribemethod should also return an object with anunsubscribemethod that enables the user to unsubscribe. When it is called, the callback should be removed from the list of subscriptions andundefinedshould be returned. - emit - This method takes in two arguments: the name of an event as a string and an optional array of arguments that will be passed to the callback(s). If there are no callbacks subscribed to the given event, return an empty array. Otherwise, return an array of the results of all callback calls in the order they were subscribed.
Example 1:
Input:
actions = ["EventEmitter", "emit", "subscribe", "subscribe", "emit"],
values = [[], ["firstEvent", "function cb1() { return 5; }"], ["firstEvent", "function cb1() { return 6; }"], ["firstEvent"]]
Output: [[],["emitted",[]],["subscribed"],["subscribed"],["emitted",[5,6]]]
Explanation:
const emitter = new EventEmitter();
emitter.emit("firstEvent"); // [], no callback are subscribed yet
emitter.subscribe("firstEvent", function cb1() { return 5; });
emitter.subscribe("firstEvent", function cb2() { return 6; });
emitter.emit("firstEvent"); // [5, 6], returns the output of cb1 and cb2
Example 2:
Input:
actions = ["EventEmitter", "subscribe", "emit", "emit"],
values = [[], ["firstEvent", "function cb1(...args) { return args.join(','); }"], ["firstEvent", [1,2,3]], ["firstEvent", [3,4,6]]]
Output: [[],["subscribed"],["emitted",["1,2,3"]],["emitted",["3,4,6"]]]
Explanation: Note that the emit method should be able to accept an OPTIONAL array of arguments.
const emitter = new EventEmitter();
emitter.subscribe("firstEvent, function cb1(...args) { return args.join(','); });
emitter.emit("firstEvent", [1, 2, 3]); // ["1,2,3"]
emitter.emit("firstEvent", [3, 4, 6]); // ["3,4,6"]
Example 3:
Input:
actions = ["EventEmitter", "subscribe", "emit", "unsubscribe", "emit"],
values = [[], ["firstEvent", "(...args) => args.join(',')"], ["firstEvent", [1,2,3]], [0], ["firstEvent", [4,5,6]]]
Output: [[],["subscribed"],["emitted",["1,2,3"]],["unsubscribed",0],["emitted",[]]]
Explanation:
const emitter = new EventEmitter();
const sub = emitter.subscribe("firstEvent", (...args) => args.join(','));
emitter.emit("firstEvent", [1, 2, 3]); // ["1,2,3"]
sub.unsubscribe(); // undefined
emitter.emit("firstEvent", [4, 5, 6]); // [], there are no subscriptions
Example 4:
Input:
actions = ["EventEmitter", "subscribe", "subscribe", "unsubscribe", "emit"],
values = [[], ["firstEvent", "x => x + 1"], ["firstEvent", "x => x + 2"], [0], ["firstEvent", [5]]]
Output: [[],["subscribed"],["emitted",["1,2,3"]],["unsubscribed",0],["emitted",[7]]]
Explanation:
const emitter = new EventEmitter();
const sub1 = emitter.subscribe("firstEvent", x => x + 1);
const sub2 = emitter.subscribe("firstEvent", x => x + 2);
sub1.unsubscribe(); // undefined
emitter.emit("firstEvent", [5]); // [7]
Constraints:
1 <= actions.length <= 10values.length === actions.length- All test cases are valid, e.g. you don't need to handle scenarios when unsubscribing from a non-existing subscription.
- There are only 4 different actions:
EventEmitter,emit,subscribe, andunsubscribe. - The
EventEmitteraction doesn't take any arguments. - The
emitaction takes between either 1 or 2 arguments. The first argument is the name of the event we want to emit, and the 2nd argument is passed to the callback functions. - The
subscribeaction takes 2 arguments, where the first one is the event name and the second is the callback function. - The
unsubscribeaction takes one argument, which is the 0-indexed order of the subscription made before.
Solution:
class EventEmitter {
constructor() {
this.events = {};
}
/**
* @param {string} eventName
* @param {Function} callback
* @return {Object}
*/
subscribe(eventName, callback) {
this.events[eventName] = this.events[eventName] ?? [];
this.events[eventName].push(callback);
return {
unsubscribe: () => {
this.events[eventName] = this.events[eventName].filter((f) => f !== callback);
// To avoid memory leaks adding a cleanup condition
if (this.events[eventName].length === 0) { delete this.events[eventName] }
}
};
}
/**
* @param {string} eventName
* @param {Array} args
* @return {Array}
*/
emit(eventName, args = []) {
if (!(eventName in this.events)) return [];
return this.events[eventName].map(f => f(...args));
}
}
/**
* const emitter = new EventEmitter();
*
* // Subscribe to the onClick event with onClickCallback
* function onClickCallback() { return 99 }
* const sub = emitter.subscribe('onClick', onClickCallback);
*
* emitter.emit('onClick'); // [99]
* sub.unsubscribe(); // undefined
* emitter.emit('onClick'); // []
*/
Overview:
We are tasked with designing an EventEmitter class that allows for subscribing to events and emitting them. The EventEmitter should have the following two methods:
subscribe(eventName, callback): This method takes in the name of an event as a string and acallbackfunction. Thecallbackfunction will be called when the event is emitted. An event should be able to have multiple listeners for the same event. The callbacks should be called in the order they were subscribed. Thesubscribemethod should return an object with anunsubscribemethod that can be used to remove thecallbackfrom the list of subscriptions.emit(eventName, args): This method takes in the name of an event as a string and an optional array of arguments. It should trigger the callbacks associated with theeventName, passing the provided arguments to eachcallback. If there are no callbacks subscribed to the given event, the method should return an empty array. Otherwise, it should return an array containing the results of allcallbackcalls in the order they were subscribed.
Before going any further let us understand meaning of few terms:
Events and Event-driven Programming:
- Events represent things that happen in a program. For example, when a user clicks a button, it triggers a "click" event.
- Event-driven programming focuses on responding to events rather than following a fixed sequence of steps. It allows programs to react to user interactions and external changes.
- Example: Imagine a game where the player's character moves when the arrow keys are pressed. The game uses
eventsto detect key presses and update the character's position accordingly.
EventEmitter:
- An EventEmitter is a tool or class that manages events in a program. It allows components to subscribe to events and receive notifications when those events occur.
- Example: Think of an
EventEmitteras a radio station. It broadcasts different types of shows (events), and listeners (components) can tune in to listen to specific shows they are interested in.
Subscriptions and Callbacks:
- Subscriptions allow components to express their interest in specific events. They specify which events they want to listen to.
- Callbacks, also known as event handlers, are functions that get executed when the subscribed event occurs.
- Example: In a messaging app, a user can subscribe to the "newMessage" event to receive notifications when a new message is received. The callback function could display the message on the screen.
// Callback function for handling new messages
function handleMessageReceived(message) {
console.log("New message received:", message);
}
// Subscribe the callback function to the "newMessage" event
eventEmitter.subscribe("newMessage", handleMessageReceived);
Order of Callback Execution:
- When multiple listeners subscribe to the same event, the callbacks are executed in the order they were subscribed.
- Example: Imagine a social media app where users can like a post. Each like triggers the "postLiked" event, and all subscribed callbacks should execute in the order they were registered.
Unsubscribing from Events:
- Subscriptions can be canceled or removed when components no longer want to receive event notifications.
- Example: In a notification system, users may want to unsubscribe from email notifications after they have configured their preferences.
// Subscribe a callback function to an event and get the unsubscribe method
const subscription = eventEmitter.subscribe("eventName", callback);
// Unsubscribe from the event by calling the unsubscribe method
subscription.unsubscribe();
Event Arguments:
- Events can carry additional information or data, known as event arguments, which are passed to the callback functions.
- Example: In a weather app, the "weatherUpdate" event may include arguments such as temperature, humidity, and weather conditions. The callback function can use these arguments to update the UI.
// Callback function for handling weather updates
function handleWeatherUpdate(weatherData) {
console.log("Temperature:", weatherData.temperature);
console.log("Humidity:", weatherData.humidity);
}
// Subscribe the callback function to the "weatherUpdate" event
eventEmitter.subscribe("weatherUpdate", handleWeatherUpdate);
Return Values:
- Callbacks can perform actions or computations and return values based on their functionality.
- Example: In a calculator app, a callback function subscribed to the "calculate" event may receive arguments like numbers and an operation. It can perform the calculation and return the result.
// Callback function for handling calculations
function handleCalculation(numbers, operation) {
if (operation === "add") {
return numbers.reduce((a, b) => a + b, 0);
} else if (operation === "multiply") {
return numbers.reduce((a, b) => a * b, 1);
}
}
// Subscribe the callback function to the "calculate" event
eventEmitter.subscribe("calculate", handleCalculation);
Use Cases:
- User Interface (UI) Interactions: In web development, an
EventEmittercan be used to handle user interactions such as button clicks, form submissions, or menu selections. Components can subscribe to these events and perform appropriate actions or updates when the events are emitted.
// Create an EventEmitter instance
const eventEmitter = new EventEmitter();
// Subscribe to a button click event
eventEmitter.subscribe("buttonClick", () => {
console.log("Button clicked!");
});
// Emit the button click event
eventEmitter.emit("buttonClick");
- Asynchronous Operations: When working with asynchronous operations like fetching data from an API or handling database queries, an
EventEmittercan be used to notify components or modules about the completion or status of these operations. Subscribed callbacks can then handle the returned data or trigger subsequent actions.
// Create an EventEmitter instance
const eventEmitter = new EventEmitter();
// Simulate an asynchronous operation
function fetchData() {
setTimeout(() => {
const data = "Some fetched data";
// Emit the event with the fetched data
eventEmitter.emit("dataFetched", data);
}, 2000);
}
// Subscribe to the dataFetched event
eventEmitter.subscribe("dataFetched", (data) => {
console.log("Data fetched:", data);
});
// Trigger the asynchronous operation
fetchData();
- Custom Event-driven Systems:
EventEmitterscan be used to build custom event-driven systems for specific application needs. For example, in a game engine, anEventEmittercan be used to manage events like player movement, collision detection, or game state changes. Components, such as game objects or UI elements, can subscribe to these events and respond accordingly.
// Create an EventEmitter instance
const eventEmitter = new EventEmitter();
// Game state change event
eventEmitter.subscribe("gameStateChange", (newState) => {
console.log("Game state changed:", newState);
});
// Player movement event
eventEmitter.subscribe("playerMovement", (movement) => {
console.log("Player moved:", movement);
});
// Emit game events
eventEmitter.emit("gameStateChange", "start");
eventEmitter.emit("playerMovement", "left");
- Logging and Error Handling: An
EventEmittercan be utilized to handle logging and error events. Subscribed callbacks can capture error events, log them to a file or console, and perform error handling tasks such as sending error reports or displaying error messages to the user.
// Create an EventEmitter instance
const eventEmitter = new EventEmitter();
// Error event
eventEmitter.subscribe("error", (errorMessage) => {
console.error("Error occurred:", errorMessage);
});
// Log event
eventEmitter.subscribe("log", (message) => {
console.log("Log message:", message);
});
// Emit logging and error events
eventEmitter.emit("error", "Something went wrong!");
eventEmitter.emit("log", "Info: Application started.");
- Event-driven Architectures:
EventEmittersare a fundamental building block in event-driven architectures. They enable loose coupling and decoupling of components by allowing them to communicate through events. This promotes modularity and scalability in large-scale applications.
Approach 1: Using array
Intuition:
- When an
eventis emitted, we can check if there are any handlerssubscribedto thateventby accessing currentevent. If there are no handlers, an empty array is returned, indicating that no callbacks were executed. - If there are handlers, we can iterate over the array of handlers using the
mapmethod. For each handler, we can call the corresponding callback function with the provided arguments using the spread operator(...args). In the end return values of each callback execution are collected and returned as an array.
Algorithm:
- The
EventEmitterclass is defined with a constructor method. The constructor initializes an empty object calledeventsto store the event subscriptions. This object will hold the event names as keys and arrays of callback functions as their corresponding values. - The
subscribemethod is implemented to subscribe to an event. It takes in two parameters:event(the name of the event as a string) andcb(the callback function to be called when the event is emitted). - Inside the subscribe method:
- We check if there are any existing handlers for the current event by accessing
this.events[event]. - If there are no handlers, we can initialize an empty array using the nullish coalescing operator
(??). Nullish operator evaluates the expression on its left-hand side and, if the value isnullorundefined, it returns the expression on its right-hand side. - We can then push the provided callback function
(cb)to the array of handlers(this.events[event]).
- We check if there are any existing handlers for the current event by accessing
- The
subscribemethod returns an object with anunsubscribemethod. Theunsubscribemethod is an arrow function that removes the subscribed callback from the array of handlers for the corresponding event. - The
emitmethod is implemented to emit an event. It takes in two parameters:event(the name of the event as a string) andargs(an optional array of arguments to be passed to the callbacks). - Inside the
emitmethod:- We check if there are any handlers subscribed to the current event by accessing
this.events[event]. - If there are no handlers, we return an empty array
[]indicating that no callbacks were executed. - If there are handlers:
- We use the
mapmethod to iterate over the array of handlers(this.events[event]). - For each handler, we call the callback function
(f)with the provided arguments(...args)using the spread operator(...). - In the end the return values of each callback execution are collected and returned as an array.
- We use the
- We check if there are any handlers subscribed to the current event by accessing
Implementation:
class EventEmitter {
constructor() {
this.events = {};
}
subscribe(event, cb) {
this.events[event] = this.events[event] ?? [];
this.events[event].push(cb);
return {
unsubscribe: () => {
this.events[event] = this.events[event].filter(f => f !== cb);
//To avoid memory leaks adding a cleanup condition
if (this.events[event].length === 0) { delete this.events[event] }
},
};
}
emit(event, args = []) {
if (!(event in this.events)) return [];
return this.events[event].map(f => f(...args));
}
}
Complexity Analysis:
- Time complexity: For subscribe: O(1) & For unsubscribe and emit: O(n) , where
nrepresents the number of callbacks subscribed to the event - Space complexity: O(n) , where
nrepresents the number of callbacks subscribed to the event
Approach 2: Using Set
Intuition:
We can create a object to store each event and as same event can consist of many different callbacks, we can use a set instead of array to store each different callbacks for the same event.
Algorithm:
- The
EventEmitterclass is defined with a constructor method. The constructor initializes an empty object calledeventsto store the event subscriptions. This object will hold the event names as keys and arrays of callback functions as their corresponding values. - The
subscribemethod is implemented to subscribe to an event. It takes in two parameters:event(the name of the event as a string) andcb(the callback function to be called when the event is emitted). - Inside the
subscribemethod:- We first check if the events object does not have the specified event as its own property. If it doesn't, we initialize it with a new
Setcontaining the callback function. TheSetdata structure ensures that duplicate callbacks are not added. - If the event already exists in the
eventsobject, we add the callback function to the existing set of callbacks associated with that event. - The
subscribemethod returns an object with anunsubscribemethod. When called, this method removes the callback function from the set of subscriptions for the specific event.
- We first check if the events object does not have the specified event as its own property. If it doesn't, we initialize it with a new
- The
emitmethod is used to emit (trigger) an event. It takes in theevent(name of the event as a string) and an optionalargsarray that contains arguments to be passed to the callback functions.- Inside the
emitmethod, we first check if the specified event exists in theeventsobject. If it doesn't, we return an empty array since there are no callbacks subscribed to that event. - We create an empty
resultarray to store the results of callback function calls. - We iterate over each callback function in the set of callbacks associated with the event using the
forEachmethod. - For each callback function, we invoke it using the spread operator
(...args)to pass the arguments provided to theemitmethod. - The result of each callback function call is pushed into the
resultarray. - Finally, the result array containing the results of all callback function calls is returned.
- Inside the
Implementation:
class EventEmitter {
constructor() {
this.events = {};
}
subscribe(event, cb) {
if (!(event in this.events)) {
this.events[event] = new Set([cb]);
} else {
this.events[event].add(cb);
}
return {
unsubscribe: () => {
this.events[event].delete(cb);
},
};
}
emit(event, args = []) {
if (!(event in this.events)) return [];
const result = [];
this.events[event].forEach((fn) => {
result.push(fn(...args));
});
return result;
}
}
Complexity Analysis:
- Time complexity: For subscribe & unsubscribe: O(1), For emit: O(n) , where
nrepresents the number of callbacks subscribed to the event - Space complexity: O(n) , where
nrepresents the number of callbacks subscribed to the event
-
How would you handle events with arguments using the EventEmitter class?
- When subscribing to an event, the callback function can accept the event arguments as parameters. When emitting the event, you can pass an array of arguments to the emit method, which will be passed to the subscribed callbacks. The callbacks can then access and utilize these arguments in their implementation.
-
Can multiple callbacks be subscribed to the same event using the EventEmitter class?
- Yes, the EventEmitter class allows multiple callbacks to be subscribed to the same event. Each subscribed callback will be called in the order they were subscribed when the event is emitted.
-
How can you ensure the order of callback execution in the EventEmitter class?
- The EventEmitter class maintains the order of callback execution by storing the callbacks in the order they are subscribed. When emitting an event, the class iterates through the list of callbacks in the order they were subscribed and calls each callback function.
-
What happens when you emit an event with no subscribed callbacks using the EventEmitter class?
- If there are no callbacks subscribed to a particular event, emitting that event using the EventEmitter class will return an empty array. This indicates that no callbacks were executed because there were no listeners for the event.
-
Can you explain the difference between an EventEmitter and a simple callback function?
- While a simple callback function allows you to execute a single function when an event occurs, an EventEmitter provides a more structured and scalable way to manage events. With an EventEmitter, you can have multiple callbacks for the same event, handle subscription and unsubscription, control the order of callback execution, pass arguments to callbacks, and have a more decoupled architecture.