Comunicação entre componentes desacoplados usando eventos

8

Temos um aplicativo da Web em que temos muitos (> 50) de pequenos componentes da Web que interagem entre si.

Para manter tudo dissociado, temos como regra que nenhum componente pode fazer referência direta a outro. Em vez disso, os componentes disparam eventos que são então (no aplicativo "principal") conectados para chamar os métodos de outro componente.

Com o passar do tempo, mais e mais componentes foram adicionados e o arquivo do aplicativo "principal" ficou cheio de pedaços de código parecidos com o seguinte:

buttonsToolbar.addEventListener('request-toggle-contact-form-modal', () => {
  contactForm.toggle()
})

buttonsToolbar.addEventListener('request-toggle-bug-reporter-modal', () => {
  bugReporter.toggle()
})

// ... etc

Para melhorar isso, agrupamos funcionalidades semelhantes, em Class, dê um nome relevante, passe os elementos participantes ao instanciar e manipule a "fiação" dentro do Class, da seguinte forma:

class Contact {
  constructor(contactForm, bugReporter, buttonsToolbar) {
    this.contactForm = contactForm
    this.bugReporterForm = bugReporterForm
    this.buttonsToolbar = buttonsToolbar

    this.buttonsToolbar
      .addEventListener('request-toggle-contact-form-modal', () => {
        this.toggleContactForm()
      })

    this.buttonsToolbar
      .addEventListener('request-toggle-bug-reporter-modal', () => {
        this.toggleBugReporterForm()
      })
  }

  toggleContactForm() {
    this.contactForm.toggle()
  }

  toggleBugReporterForm() {
    this.bugReporterForm.toggle()
  }
}

e instanciamos assim:

<html>
  <contact-form></contact-form>
  <bug-reporter></bug-reporter>

  <script>
    const contact = new Contact(
      document.querySelector('contact-form'),
      document.querySelector('bug-form')
    )
  </script>
</html>

Estou muito cansado de introduzir padrões próprios, especialmente aqueles que não são realmente OOP-y, pois estou usando Classescomo meros recipientes de inicialização, por falta de uma palavra melhor.

Existe um padrão definido melhor / mais conhecido para lidar com esse tipo de tarefa que estou perdendo?

Nik Kyriakides
fonte
2
Isso realmente parece um pouco incrível.
24518 Robert Harvey
Quando me lembro corretamente, nesta pergunta anterior, você já o chamou de mediador, que também é o nome do padrão no livro do GoF, então esse é definitivamente um padrão de POO.
Doc Brown
@RobertHarvey Bem, sua palavra tem muito peso para mim; Você faria diferente? Não sei se estou pensando demais nisso.
Nik Kyriakides
2
Não pense demais nisso. Sua classe "fiação" parece SÓLIDA para mim, se funcionar e você estiver satisfeito com o nome, não importa como é chamado.
Doc Brown
1
@ NicolasKyriakides: é melhor pedir a um de seus colegas de trabalho (que certamente conhece o sistema melhor que eu) um bom nome, e não um estranho como eu na internet.
Doc Brown

Respostas:

6

O código que você tem é muito bom. O que parece um pouco desanimador é que o código de inicialização não faz parte do próprio objeto. Ou seja, você pode instanciar um objeto, mas se você esquecer de chamar sua classe de fiação, é inútil.

Considere um Centro de Notificação (também conhecido como Barramento de Eventos) definido algo como isto:

class NotificationCenter(){
    constructor(){
        this.dictionary = {}
    }
    register(message, callback){
        if not this.dictionary.contains(message){
            this.dictionary[message] = []
        }
        this.dictionary[message].append(callback)
    }
    notify(message, payload){
        if this.dictionary.contains(message){
            for each callback in this.dictionary[message]{
                callback(payload)
            }
        }
    }
}

Este é um manipulador de eventos de envio múltiplo DIY. Você seria capaz de fazer sua própria fiação simplesmente exigindo um NotificationCenter como argumento do construtor. O envio de mensagens para ele e a espera de passar as cargas úteis é o único contato que você tem com o sistema, por isso é muito SÓLIDO.

class Toolbar{
    constructor(notificationCenter){
        this.NC = notificationCenter
        this.NC.register('request-toggle-contact-form-modal', (payload) => {
            this.toggleContactForm(payload)
          }
    }
    toolbarButtonClicked(e){
        this.NC.notify('toolbar-button-click-event', e)
    }
}

Nota: usei literais de string no local para que as chaves sejam consistentes com o estilo usado na pergunta e para simplificar. Isso não é aconselhável devido ao risco de erros de digitação. Em vez disso, considere usar uma enumeração ou constantes de seqüência de caracteres.

No código acima, a Barra de Ferramentas é responsável por informar ao NotificationCenter em que tipo de eventos está interessado e publicar todas as suas interações externas por meio do método de notificação. Qualquer outra classe interessada no toolbar-button-click-eventsimplesmente se registraria no construtor.

Variações interessantes sobre esse padrão incluem:

  • Usando vários NCs para lidar com diferentes partes do sistema
  • Ter o método Notify gerar um encadeamento para cada notificação, em vez de bloquear serialmente
  • Usar uma lista de prioridades em vez de uma lista regular dentro do NC para garantir uma ordem parcial sobre quais componentes serão notificados primeiro
  • Registre o retorno de um ID que pode ser usado para cancelar o registro posteriormente
  • Ignore o argumento da mensagem e apenas despache com base na classe / tipo da mensagem

Recursos interessantes incluem:

  • Instrumentar o NC é tão fácil quanto registrar registradores para imprimir cargas úteis
  • Testar um ou mais componentes interagindo é simplesmente uma questão de instancia-los, adicionar ouvintes para os resultados esperados e enviar mensagens
  • Adicionando novos componentes ouvindo mensagens antigas é trivial
  • Adicionar novos componentes que enviam mensagens aos antigos é trivial

Algumas dicas interessantes e possíveis soluções incluem:

  • Eventos que acionam outros eventos podem ficar confusos.
    • Inclua um ID de remetente no evento para identificar a origem de um evento inesperado.
  • Cada componente não tem idéia se alguma parte do sistema está em funcionamento antes de receber um evento, portanto, as mensagens iniciais podem ser descartadas.
    • Isso pode ser tratado pelo código que cria os componentes que enviam uma mensagem 'system ready', da qual os componentes interessados ​​precisam se registrar.
  • O barramento de eventos cria uma interface implícita entre componentes, o que significa que não há como o compilador ter certeza de que implementou tudo o que deveria.
    • Os argumentos padrão entre estático e dinâmico se aplicam aqui.
  • Essa abordagem agrupa componentes, não necessariamente comportamento. O rastreamento de eventos pelo sistema pode exigir mais trabalho aqui do que a abordagem do OP. Por exemplo, o OP poderia ter todos os ouvintes relacionados à economia configurados juntos e os ouvintes relacionados à exclusão configurados juntos em outro lugar.
    • Isso pode ser mitigado com uma boa nomeação e documentação de eventos, como um fluxograma. (Sim, a documentação está famosa fora de sintonia com o código). Você também pode adicionar listas de manipuladores pré e pós-catchall que recebem todas as mensagens e imprimem quem enviou o quê em que ordem.
Joel Harmon
fonte
Essa abordagem parece remanescente da arquitetura do barramento de eventos. A única diferença é que seu registro é uma sequência de tópicos em vez de um tipo de mensagem. A principal fraqueza do uso de strings é que elas estão sujeitas a erros de digitação - o que significa que a notificação ou o ouvinte pode ser digitado incorretamente e seria difícil depurar.
Berin Loritsch
Tanto quanto posso dizer, este é um exemplo clássico de mediador . Um problema com essa abordagem é que ela une um componente ao barramento / mediador de eventos. E se eu quiser mover um componente, por exemplo, buttonsToolbarpara outro projeto que não use um barramento de eventos?
Nik Kyriakides
+1 O benefício do mediador é que ele permite que você se registre contra strings / enums e tenha o acoplamento solto dentro da classe. Se você mover a fiação fora do objeto para sua classe principal / de configuração, ele conhecerá todos os objetos e poderá conectá-los diretamente a eventos / funções sem se preocupar com o acoplamento. @NicholasKyriakides Escolha um ou o outro ao invés de tentar usar os dois
Ewan
Com a arquitetura clássica de barramento de eventos, o único acoplamento é a própria mensagem. A mensagem geralmente é um objeto imutável. O objeto que envia mensagens precisa apenas da interface do editor para enviar as mensagens. Se você usar o tipo do objeto de mensagem, precisará publicar apenas o objeto da mensagem. Se você usar uma sequência, precisará fornecer a sequência de tópicos e a carga útil da mensagem (a menos que a sequência seja a carga útil). O uso de strings significa que você só precisa ser meticuloso com os valores dos dois lados.
Berin Loritsch
@NicholasKyriakides O que acontece se você mover o código original para uma nova solução? Você precisa trazer sua classe de instalação e alterá-la para seu novo contexto. O mesmo se aplica a esse padrão.
Joel Harmon
3

Eu costumava introduzir algum tipo de "barramento de eventos" e, nos anos seguintes, comecei a confiar cada vez mais no próprio Modelo de Objeto de Documento para comunicar eventos para o código da interface do usuário.

Em um navegador, o DOM é a única dependência que está sempre presente - mesmo durante o carregamento da página. A chave é utilizar eventos personalizados em JavaScript e confiar na borbulhar de eventos para comunicar esses eventos.

Antes de as pessoas começarem a gritar sobre "aguardar a conclusão do documento" antes de anexar assinantes, a document.documentElementpropriedade faz referência ao <html>elemento a partir do momento em que o JavaScript inicia a execução, independentemente de onde o script é importado ou da ordem em que aparece na sua marcação.

É aqui que você pode começar a ouvir eventos.

É muito comum ter um componente JavaScript (ou widget) dentro de uma determinada tag HTML na página. O elemento "raiz" do componente é onde você pode acionar seus eventos de bolhas. Os assinantes do <html>elemento receberão essas notificações como qualquer outro evento gerado pelo usuário.

Apenas um exemplo de código da placa da caldeira:

(function (window, document, html) {
    html.addEventListener("custom-event-1", function (event) {
        // ...
    });
    html.addEventListener("custom-event-2", function (event) {
        // ...
    });

    function someOperation() {
        var customData = { ... };
        var event = new CustomEvent("custom-event-3", { detail : customData });

        event.dispatchEvent(componentRootElement);
    }
})(this, this.document, this.document.documentElement);

Então o padrão se torna:

  1. Usar eventos personalizados
  2. Inscreva-se nesses eventos na document.documentElementpropriedade (não é necessário aguardar o documento estar pronto)
  3. Publique eventos em um elemento raiz para seu componente ou o document.documentElement.

Isso deve funcionar para bases de código funcionais e orientadas a objetos.

Greg Burghardt
fonte
1

Eu uso esse mesmo estilo no meu desenvolvimento de videogame com o Unity 3D. Crio componentes como Saúde, Entrada, Estatísticas, Som, etc. e os adiciono a um Objeto de jogo para criar o que é esse objeto de jogo. O Unity já tem mecânica para adicionar componentes aos objetos do jogo. No entanto, o que eu descobri foi que quase todo mundo estava consultando componentes ou fazendo referência direta a componentes dentro de outros componentes (mesmo que eles usassem interfaces, ainda é mais acoplado, obrigado pela preferência). Eu queria que os componentes pudessem ser criados isoladamente com zero dependências de quaisquer outros componentes. Então, eu tive os componentes disparando eventos quando os dados foram alterados (específicos ao componente) e declarando métodos para alterar basicamente os dados. Em seguida, o objeto do jogo para o qual eu criei uma classe e colei todos os eventos de componentes em outros métodos de componentes.

O que eu mais gosto nisso é que, para ver todas as interações dos componentes de um objeto de jogo, posso apenas olhar para essa classe 1. Parece que sua classe Contact é muito parecida com as minhas classes Game Object (eu nomeio objetos de jogo para o objeto que eles devem ser como MainPlayer, Orc, etc).

Essas classes são uma espécie de classes de gerente. Eles mesmos realmente não têm nada, exceto instâncias de componentes e o código para conectá-los. Não sei por que você cria métodos aqui que chamam outros métodos de componentes quando você pode conectá-los diretamente. O objetivo desta aula é realmente apenas organizar o evento.

Como uma observação lateral para meus registros de eventos, adicionei um retorno de chamada de filtro e retorno de chamada de args. Quando o evento é acionado (eu criei minha própria classe de evento personalizada), ele chamará o retorno de chamada do filtro, se houver, e se retornar true, passará para o retorno de chamada args. O objetivo do retorno de chamada do filtro era dar flexibilidade. Um evento pode ser acionado por vários motivos, mas eu só quero chamar meu evento conectado se uma verificação for verdadeira. Um exemplo pode ser um componente de entrada com um evento OnKeyHit. Se eu tiver um componente Movimento que possui métodos como MoveForward () MoveBackward (), etc, eu posso conectar o OnKeyHit + = MoveForward, mas obviamente eu não gostaria de avançar com nenhum pressionamento de tecla. Eu só gostaria de fazer isso se a chave fosse 'w'. Como o OnKeyHit está preenchendo argumentos para passar adiante e um deles é a chave que foi atingida,

Para mim, a assinatura de uma classe específica de gerenciador de objetos de jogo se parece mais com:

input.OnKeyHit.Subscribe(movement.MoveForward, (args) => { return args.Key == 'w' });

Como os componentes podem ser desenvolvidos isoladamente, vários programadores poderiam tê-los codificado. Com o exemplo acima, o codificador de entrada deu ao objeto de argumento uma variável chamada Key. No entanto, o desenvolvedor do componente Movement pode não ter usado Key (se for necessário examinar os argumentos, nesse caso provavelmente não, mas em outros, eles usam os valores de argumento passados). Para remover esse requisito de comunicação, o retorno de chamada args atua como um mapeamento para os argumentos entre os componentes. Portanto, a pessoa que faz essa classe de gerenciador de objetos de jogo é aquela que precisa apenas conhecer os nomes das variáveis ​​arg entre os 2 clientes quando eles os conectam e realizam o mapeamento nesse ponto. Este método é chamado após a função de filtro.

input.OnKeyHit.Subscribe(movement.MoveForward, (args) => { return args.Key == 'w' }, (args) => { args.keyPressed = args.Key });

Portanto, na situação acima, a pessoa de Entrada nomeou uma variável dentro do objeto args 'Key', mas o Movimento o nomeou 'keyPressed'. Isso ajuda ainda mais o isolamento entre os componentes em si, à medida que eles estão sendo desenvolvidos, e coloca o implementador da classe gerente na conexão correta.

user441521
fonte
0

Pelo que vale, estou fazendo algo como parte de um projeto de back-end e adotei uma abordagem semelhante:

  • Meu sistema não envolve widgets (componentes da Web), mas 'adaptadores' abstratos, implementações concretas que lidam com protocolos diferentes.
  • Um protocolo é modelado como um conjunto de possíveis 'conversas'. O adaptador de protocolo aciona essas conversas, dependendo de um evento recebido.
  • Existe um barramento de eventos que é basicamente um assunto de Rx.
  • O barramento de eventos assina a saída de todos os adaptadores e todos os adaptadores assinam a saída do barramento de eventos.
  • O 'adaptador' é modelado como o fluxo agregado de todas as suas 'conversas'. Uma conversa é um fluxo inscrito na saída do barramento de eventos, gerando mensagens para o barramento de eventos, conduzido por uma Máquina de Estado.

Como eu lidei com seus desafios de construção / fiação:

  • Um protocolo (implementado pelo adaptador) define os critérios de início de conversação como filtros no fluxo de entrada ao qual está inscrito. Em C #, essas são consultas LINQ sobre fluxos. No ReactJS, esses seriam operadores .Where ou .Filter.
  • Uma conversa decide o que é uma mensagem relevante usando seus próprios filtros.
  • Em geral, qualquer coisa inscrita no barramento é um fluxo e o barramento é inscrito nesses fluxos.

A analogia com sua barra de ferramentas:

  • A classe da barra de ferramentas é um .Mapa de uma entrada observável (o barramento), que é um observável dos eventos da barra de ferramentas, nos quais o barramento está inscrito
  • Um observável das barras de ferramentas (se você multiplica as sub-barras de ferramentas) significa que você pode ter vários observáveis; portanto, sua barra de ferramentas é um observável dos observáveis. Estes seriam RxJs .Merge'd em uma única saída para o barramento.

Problemas que você pode enfrentar:

  • Garantir que os eventos não sejam cíclicos e interrompa o processo.
  • Simultaneidade (não sei se isso é relevante para WebComponents): para operações assíncronas ou operações que podem ser demoradas, seu manipulador de eventos pode bloquear o encadeamento observável se não for executado como uma tarefa em segundo plano. Os agendadores RxJS podem resolver isso (por padrão, você pode .ObserveOn um agendador padrão para todas as assinaturas de barramento, por exemplo)
  • Cenários mais complexos que não podem ser modelados sem alguma noção de conversa (por exemplo: manipular um evento enviando uma mensagem e aguardando uma resposta, que é ela própria um evento). Nesse caso, uma máquina de estado é útil para especificar dinamicamente quais eventos você deseja manipular (conversa no meu modelo). Eu faço isso fazendo com que o fluxo de conversação seja de .filteracordo com o estado (na verdade, a implementação é mais funcional - a conversa é um mapa plano de observáveis ​​de um evento observável de mudança de estado).

Portanto, em resumo, você pode considerar todo o domínio do problema como observável ou 'funcionalmente' / 'declarativamente' e considerar seus componentes da Web como fluxos de eventos, como observáveis, derivados do barramento (um observável), ao qual o barramento (um observador) também está inscrito. A instanciação de observáveis ​​(por exemplo: uma nova barra de ferramentas) é declarativa, pois todo o processo pode ser visto como observável dos observáveis .map'd do fluxo de entrada.

Sentinela
fonte