Building a Custom Event Emitter in JavaScript

Last Modified: December 31, 2024

Let's build a powerful and flexible event emitter system in JavaScript, understanding the core concepts of event-driven programming. 🚀

Understanding Event Emitters 🎯

Event emitters implement the Observer pattern, allowing objects to communicate through custom events. They're fundamental to event-driven programming in JavaScript.

Basic Implementation

Core EventEmitter Class

class EventEmitter {
  constructor() {
    this.events = new Map();
  }

  on(event, callback) {
    if (!this.events.has(event)) {
      this.events.set(event, []);
    }

    this.events.get(event).push(callback);
    return this;
  }

  emit(event, ...args) {
    if (!this.events.has(event)) {
      return false;
    }

    this.events.get(event).forEach(callback => {
      callback.apply(this, args);
    });

    return true;
  }
}

Basic Usage

const emitter = new EventEmitter();

emitter.on('userLogin', (user) => {
  console.log(`${user.name} logged in`);
});

emitter.emit('userLogin', { name: 'John' });

Advanced Features 🌟

1. Once Listeners

class EventEmitter {
  // ... previous methods ...

  once(event, callback) {
    const wrapper = (...args) => {
      this.off(event, wrapper);
      callback.apply(this, args);
    };

    wrapper.original = callback;
    return this.on(event, wrapper);
  }
}

// Usage
emitter.once('firstLogin', (user) => {
  console.log(`Welcome ${user.name}!`);
});

2. Remove Listeners

class EventEmitter {
  // ... previous methods ...

  off(event, callback) {
    if (!this.events.has(event)) {
      return this;
    }

    if (!callback) {
      this.events.delete(event);
      return this;
    }

    const listeners = this.events.get(event);
    const filtered = listeners.filter(listener => {
      return listener !== callback && 
             listener.original !== callback;
    });

    if (filtered.length === 0) {
      this.events.delete(event);
    } else {
      this.events.set(event, filtered);
    }

    return this;
  }
}

3. Error Handling

class EventEmitter {
  // ... previous methods ...

  emit(event, ...args) {
    if (event === 'error' && !this.events.has('error')) {
      throw args[0];
    }

    return super.emit(event, ...args);
  }
}

// Usage
emitter.on('error', (error) => {
  console.error('Caught error:', error);
});

Real-World Implementation 🔄

Complete EventEmitter

class EventEmitter {
  constructor(options = {}) {
    this.events = new Map();
    this.maxListeners = options.maxListeners || 10;
  }

  addListener(event, listener) {
    return this.on(event, listener);
  }

  on(event, listener) {
    if (!this.events.has(event)) {
      this.events.set(event, []);
    }

    const listeners = this.events.get(event);

    if (listeners.length >= this.maxListeners) {
      console.warn(
        `MaxListenersExceededWarning: Possible memory leak detected. 
         ${listeners.length} ${event} listeners added.`
      );
    }

    listeners.push(listener);
    return this;
  }

  once(event, listener) {
    const wrapper = (...args) => {
      this.off(event, wrapper);
      listener.apply(this, args);
    };

    wrapper.original = listener;
    return this.on(event, wrapper);
  }

  off(event, listener) {
    return this.removeListener(event, listener);
  }

  removeListener(event, listener) {
    if (!this.events.has(event)) {
      return this;
    }

    const listeners = this.events.get(event);
    const filtered = listeners.filter(l => {
      return l !== listener && l.original !== listener;
    });

    if (filtered.length === 0) {
      this.events.delete(event);
    } else {
      this.events.set(event, filtered);
    }

    return this;
  }

  removeAllListeners(event) {
    if (event) {
      this.events.delete(event);
    } else {
      this.events.clear();
    }

    return this;
  }

  listeners(event) {
    return this.events.get(event) || [];
  }

  emit(event, ...args) {
    if (!this.events.has(event)) {
      if (event === 'error') {
        throw args[0];
      }
      return false;
    }

    const listeners = this.events.get(event);
    listeners.forEach(listener => {
      try {
        listener.apply(this, args);
      } catch (error) {
        this.emit('error', error);
      }
    });

    return true;
  }

  listenerCount(event) {
    return this.listeners(event).length;
  }
}

Practical Examples 💡

1. Chat Application

class ChatRoom extends EventEmitter {
  constructor() {
    super();
    this.users = new Set();
  }

  addUser(user) {
    this.users.add(user);
    this.emit('userJoined', user);
  }

  removeUser(user) {
    this.users.delete(user);
    this.emit('userLeft', user);
  }

  sendMessage(user, message) {
    this.emit('message', { user, message });
  }
}

const chat = new ChatRoom();

chat.on('userJoined', (user) => {
  console.log(`${user.name} joined the chat`);
});

chat.on('message', ({ user, message }) => {
  console.log(`${user.name}: ${message}`);
});

2. Data Store

class Store extends EventEmitter {
  constructor(initialState = {}) {
    super();
    this.state = initialState;
  }

  setState(newState) {
    const oldState = { ...this.state };
    this.state = { ...this.state, ...newState };
    this.emit('stateChanged', this.state, oldState);
  }

  getState() {
    return this.state;
  }
}

const store = new Store({ count: 0 });

store.on('stateChanged', (newState, oldState) => {
  console.log('State changed:', { oldState, newState });
});

Performance Optimization 📊

1. Lazy Event Creation

class OptimizedEmitter extends EventEmitter {
  on(event, listener) {
    if (!this.events) {
      this.events = new Map();
    }
    return super.on(event, listener);
  }
}

2. Event Batching

class BatchEmitter extends EventEmitter {
  constructor() {
    super();
    this.batchedEvents = new Map();
    this.batchTimeout = null;
  }

  emitBatch(event, data) {
    if (!this.batchedEvents.has(event)) {
      this.batchedEvents.set(event, []);
    }

    this.batchedEvents.get(event).push(data);

    if (!this.batchTimeout) {
      this.batchTimeout = setTimeout(() => {
        this.flush();
      }, 0);
    }
  }

  flush() {
    this.batchedEvents.forEach((data, event) => {
      this.emit(event, data);
    });

    this.batchedEvents.clear();
    this.batchTimeout = null;
  }
}

Best Practices 📝

  1. Always handle error events
  2. Clean up listeners when they're no longer needed
  3. Use meaningful event names
  4. Document event payloads
  5. Implement proper error handling
  6. Consider memory management
  7. Use TypeScript for better type safety
  8. Test event handling thoroughly
  9. Implement proper cleanup methods
  10. Monitor listener count

Additional Resources

Event emitters are a powerful tool for building event-driven applications. By implementing your own event emitter, you gain a deeper understanding of event-driven architecture and can customize the implementation to your specific needs.