Artigo original: How to code your own event emitter in Node.js: a step-by-step guide

Escrito por: Rajesh Pillai

Entenda o funcionamento do Node programando pequenos pacotes/módulos

Se você é novo no Node.js, há muitos tutoriais aqui no freeCodeCamp e em outros lugares. Você pode conferir meu artigo All about Core Node.JS (em inglês), por exemplo.

Sem mais delongas, vamos ao assunto em discussão: "Emissores de Eventos". Os emissores de eventos desempenham um papel muito importante no ecossistema do Node.js.

O EventEmitter (Emissor de Eventos) é um módulo que facilita a comunicação/interação entre objetos no Node. O EventEmitter está no centro da arquitetura orientada a eventos assíncrona do Node. Muitos dos módulos internos do Node são herdados do EventEmitter, incluindo frameworks proeminentes, como o Express.js.

O conceito é bastante simples: objetos emissores emitem eventos nomeados, que fazem com que ouvintes (em inglês, listeners) previamente registrados sejam chamados. Assim, um objeto emissor possui basicamente duas funcionalidades principais:

  • Emitir eventos nomeados.
  • Registrar e cancelar o registro de funções do ouvinte.

É meio como um padrão de desenho pub/sub ou observador (embora não exatamente).

O que criaremos neste tutorial

  • classe EventEmitter
  • método on/addEventListener
  • método off/removeEventListener
  • método once
  • método emit
  • método rawListeners
  • método listenerCount

Os recursos básicos acima são suficientes para implementar um sistema completo usando o modelo de eventos.

Antes de entrarmos na programação, vamos dar uma olhada em como usaremos a classe EventEmitter. Observe que nosso código imitará a API exata do módulo "events" do Node.js.

Na verdade, se você substituir nosso EventEmitter pelo módulo "events" integrado do Node.js, você obterá o mesmo resultado.

Exemplo 1 - Crie uma instância de emissor de evento e registre algumas funções de retorno

const myEmitter = new EventEmitter();

function c1() {
   console.log('Ocorreu um evento!');
}

function c2() {
   console.log('E mais outro!');
}

myEmitter.on('eventOne', c1); // Registro para eventOne
myEmitter.on('eventOne', c2); // Registro para eventOne

Quando o evento "eventOne" é emitido, ambas as funções de retorno acima devem ser invocadas.

myEmitter.emit('eventOne');

A saída no console será a seguinte:

Ocorreu um evento!
E mais outro!

Exemplo 2 - Registrando para o evento ser disparado apenas uma vez usando "once"

myEmitter.once('eventOnce', () => console.log('eventOnce disparado apenas uma vez')); 

Emitindo o evento "eventOnce":

myEmitter.emit('eventOne');

A seguinte saída deve aparecer no console:

eventOnce disparado apenas uma vez

Emitir eventos registrados com "once" novamente não terá impacto.

myEmitter.emit('eventOne');

Uma vez que o evento foi emitido apenas uma vez, a instrução acima não terá impacto.

Exemplo 3 - Registrando-se para o evento com parâmetros de função de retorno

myEmitter.on('status', (code, msg)=> console.log(`Obtive ${code} e ${msg}`));

Emitindo o evento com parâmetros:

myEmitter.emit('status', 200, 'ok');

A saída no console será a seguinte:

Obtive 200 e ok

OBSERVAÇÃO: Você pode emitir eventos várias vezes (exceto os registrados com o método "once").

Exemplo 4 - Cancelando o registro de eventos

myEmitter.off('eventOne', c1);

Agora, se você emitir o evento da seguinte forma, nada acontecerá e será um "noop":

myEmitter.emit('eventOne');  // noop

Exemplo 5 - Obtendo contagem de ouvintes

console.log(myEmitter.listenerCount('eventOne'));

OBSERVAÇÃO: Se o registro do evento foi cancelado usando o método "off" ou "removeListener", a contagem será 0.

Exemplo 6 - Obtendo ouvintes brutos

console.log(myEmitter.rawListeners('eventOne'));

Exemplo 7 - Exemplo de demonstração assíncrona

// Exemplo 2->Adaptado de um texto de Sameer Buna. Grato!
class WithTime extends EventEmitter {
  execute(asyncFunc, ...args) {
    this.emit('begin');
    console.time('execute');
    this.on('data', (data)=> console.log('got data ', data));
    asyncFunc(...args, (err, data) => {
      if (err) {
        return this.emit('error', err);
      }
      this.emit('data', data);
      console.timeEnd('execute');
      this.emit('end');
    });
  }
}

Usando o emissor de evento "withTime":

const withTime = new WithTime();

withTime.on('begin', () => console.log('Prestes a executar'));
withTime.on('end', () => console.log('Execução encerrada'));

const readFile = (url, cb) => {
  fetch(url)
    .then((resp) => resp.json()) // Transformar os dados em json
    .then(function(data) {
      cb(null, data);
    });
}

withTime.execute(readFile, 'https://jsonplaceholder.typicode.com/posts/1');

Verifique a saída no console. A lista de postagens será exibida junto com outros logs.

O padrão Observador para nosso emissor de evento

image-2

Diagrama visual 1 (métodos em nosso EventEmitter)

image-4

Como agora entendemos a utilização da API, vamos programar o módulo.

O código de exemplo completo para a classe EventEmitter

Estaremos preenchendo os detalhes de modo incremental nas próximas seções.

class EventEmitter {
  listeners = {};  // par chave-valor
  
  addListener(eventName, fn) {}
  on(eventName, fn) {}
  
  removeListener(eventName, fn) {}
  off(eventName, fn) {}
  
  once(eventName, fn) {}
  
  emit(eventName, ...args) { }
  
  listenerCount(eventName) {}
  
  rawListeners(eventName) {}
}

Começamos criando o modelo para a classe EventEmitter junto com um objeto para armazenar os ouvintes. Os ouvintes serão armazenados como um par chave-valor. O valor pode ser uma lista (já que para o mesmo evento permitimos que vários ouvintes sejam cadastrados).

1. O método addListener()

Vamos agora implementar o método addListener. Ele recebe um nome de evento e uma função de retorno para ser executada.

addListener(event, fn) {
    this.listeners[event] = this.listeners[event] || [];
    this.listeners[event].push(fn);
    return this;
  }

Uma pequena explicação:

O evento addListener verifica se o evento já está registrado. Se estiver, retorna o array. Caso contrário, o array estará vazio.

this.listeners[event] // retornará o array de eventos ou undefined (registro inicial)

Por exemplo…

Vamos entender isso com um exemplo de uso. Vamos criar um eventEmitter e registrar um "test-event". Esta é a primeira vez que o "test-event" está sendo registrado.

const eventEmitter = new EventEmitter();
eventEmitter.addListener('test-event', 
 ()=> { console.log ("test one") } 
);

Dentro do método addListener():

this.listeners[event] =>  this.listeners['test-event'] 
                  => undefined || []
                  => []

O resultado será:

this.listeners['test-event'] = [];  // empty array

Espero que isso torne o método "addListener" muito claro para decifrar e entender.

Uma observação: várias funções de retornos podem ser registradas para o mesmo evento.

image-5

2. O método on()

Este é apenas um apelido para o método "addListener". Usaremos o método "on" mais do que o método "addListener" por uma questão de conveniência.

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

3. O método removeListener(event, fn)

O método "removeListener" recebe um nome de evento e a função de retorno como parâmetros. Ele remove o ouvinte mencionado do array de eventos.

OBSERVAÇÃO: Se o evento tiver vários ouvintes, os outros ouvintes não serão afetados.

Primeiro, vamos dar uma olhada no código completo para "removeListener".

removeListener (event, fn) {
    let lis = this.listeners[event];
    if (!lis) return this;
    for(let i = lis.length; i > 0; i--) {
      if (lis[i] === fn) {
        lis.splice(i,1);
        break;
      }
    }
    return this;
}

Aqui está o método "removeListener" explicado passo a passo:

  • Obtém o array de ouvintes por evento.
  • Se nenhum for encontrado, retornar "this" para encadeamento.
  • Se encontrado, percorre todos os ouvintes. Se o ouvinte atual corresponde ao parâmetro "fn", usa o método "splice" do array para removê-lo. Depois, sai do laço.
  • Retorna "this" para continuar encadeando.

4. O método off(event, fn)

Este é apenas um apelido para o método "removeListener". Estaremos usando o método "off" mais do que o método "removeListener" por uma questão de conveniência.

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

5. O método once(eventName, fn)

Adiciona uma função de ouvinte única para o evento chamado "eventName". Na próxima vez que o nome do evento for acionado, esse ouvinte será removido e, em seguida, invocado.

Use para eventos do tipo configuração/inicialização.

Vamos dar uma olhada no código.

once(eventName, fn) {
    this.listeners[event] = this.listeners[eventName] || [];
    const onceWrapper = () => {
      fn();
      this.off(eventName, onceWrapper);
    }
    this.listeners[eventName].push(onceWrapper);
    return this;
}

Aqui está o método "once" explicado passo a passo:

  • Obtém o objeto de array de eventos. Se for a primeira vez, o array estará vazio.
  • Cria uma função de invólucro chamada "onceWrapper", que invocará a função "fn" quando o evento for emitido e também removerá o ouvinte.
  • Adiciona a função de invólucro ao array.
  • Retorna "this" para encadeamento.

6. O método emit(eventName, ..args)

Chama de modo síncrono cada um dos ouvintes registrados para o evento com o nome "eventName", na ordem em que foram registrados, passando os argumentos fornecidos para cada um.

Retorna verdadeiro (true) se o evento tiver ouvintes ou falso (false), do contrário.

emit(eventName, ...args) {
    let fns = this.listeners[eventName];
    if (!fns) return false;
    fns.forEach((f) => {
      f(...args);
    });
    return true;
}
image-1-1

Aqui está o método "emit" explicado passo a passo:

  • Obtém as funções registradas para o parâmetro nome do evento.
  • Se não houver ouvintes, retorna false.
  • Para todos os ouvintes de função, invoca a função com os argumentos fornecidos.
  • Retorna true quando termina.

7. O método listenerCount(eventName)

Retorna o número de ouvintes escutando o evento com o nome "eventName".

Aqui está o código-fonte:

listenerCount(eventName) {
    let fns = this.listeners[eventName] || [];
    return fns.length;
}

Aqui está o método "listenerCount" explicado passo a passo:

  1. Obtém as funções/ouvintes consideradas ou um array vazio se não houver nenhum.
  2. Retorna o comprimento (desse array).

8. O método rawListeners(eventName)

Retorna uma cópia do array de ouvintes para o evento com o nome "eventName", incluindo quaisquer invólucros (como os criados por .once()). Os invólucros de .once() nesta implementação não estarão disponíveis se o evento tiver sido emitido uma vez.

rawListeners(event) {
    return this.listeners[event];
}

O código-fonte completo, para referência:

class EventEmitter {
  listeners = {}
  
  addListener(eventName, fn) {
    this.listeners[eventName] = this.listeners[eventName] || [];
    this.listeners[eventName].push(fn);
    return this;
  }

  on(eventName, fn) {
    return this.addListener(eventName, fn);
  }

  once(eventName, fn) {
    this.listeners[eventName] = this.listeners[eventName] || [];
    const onceWrapper = () => {
      fn();
      this.off(eventName, onceWrapper);
    }
    this.listeners[eventName].push(onceWrapper);
    return this;
  }

  off(eventName, fn) {
    return this.removeListener(eventName, fn);
  }

  removeListener (eventName, fn) {
    let lis = this.listeners[eventName];
    if (!lis) return this;
    for(let i = lis.length; i > 0; i--) {
      if (lis[i] === fn) {
        lis.splice(i,1);
        break;
      }
    }
    return this;
  }

  emit(eventName, ...args) {
    let fns = this.listeners[eventName];
    if (!fns) return false;
    fns.forEach((f) => {
      f(...args);
    });
    return true;
  }

  listenerCount(eventName) {
    let fns = this.listeners[eventName] || [];
    return fns.length;
  }

  rawListeners(eventName) {
    return this.listeners[eventName];
  }
}

O código completo está disponível aqui.

Como exercício, sinta-se à vontade para implementar outras APIs de eventos da documentação (em inglês).

Se você gostou deste artigo e deseja ver mais artigos semelhantes, sinta-se à vontade para compartilhá-lo. :)

OBSERVAÇÃO: O código foi otimizado para legibilidade e não para desempenho. Talvez, como exercício, você possa otimizar o código e compartilhá-lo. Não testei completamente para casos extremos e algumas validações podem estar imprecisas, pois foi um rascunho rápido.

Este artigo faz parte de um curso em vídeo que criariei, chamado "Node.JS Master Class — Build Your Own ExpressJS-Like MVC Framework from scratch".

O título do curso, porém, ainda não é definitivo.