Kommunikation zwischen entkoppelten Komponenten über Ereignisse

8

Wir haben eine Web-App, in der wir viele (> 50) kleine WebComponents haben , die miteinander interagieren.

Um alles entkoppelt zu halten, haben wir in der Regel, dass keine Komponente direkt auf eine andere verweisen kann. Stattdessen lösen Komponenten Ereignisse aus, die dann (in der "Haupt" -App) verdrahtet werden, um die Methoden einer anderen Komponente aufzurufen.

Mit der Zeit wurden immer mehr Komponenten hinzugefügt und die "Haupt" -App-Datei wurde mit Codeblöcken übersät, die so aussahen:

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

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

// ... etc

Um dies zu verbessern, haben wir ähnliche Funktionen zusammengefasst, in a Class, nennen Sie es etwas Relevantes, übergeben Sie die beteiligten Elemente beim Instanziieren und behandeln Sie die "Verkabelung" innerhalb derClass , wie folgt:

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()
  }
}

und wir instanziieren so:

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

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

Ich bin es wirklich leid, eigene Muster einzuführen, insbesondere solche, die seit meiner Verwendung nicht wirklich OOP-y sind Classes als Initialisierungscontainer verwende, kein besseres Wort habe.

Gibt es ein besseres / bekannteres definiertes Muster für die Bearbeitung dieser Art von Aufgaben, die mir fehlen?

Nik Kyriakides
quelle
2
Das sieht tatsächlich leicht genial aus.
Robert Harvey
Wenn ich mich richtig erinnere, haben Sie es in Ihrer früheren Frage bereits als Mediator bezeichnet. Dies ist auch der Mustername aus dem GoF-Buch. Dies ist also definitiv ein OOP-Muster.
Doc Brown
@ RobertHarvey Nun, dein Wort hat viel Gewicht für mich; Würden Sie es anders machen? Ich bin mir nicht sicher, ob ich das überdenke.
Nik Kyriakides
2
Überdenken Sie das nicht. Ihre "Verkabelungs" -Klasse sieht für mich FEST aus. Wenn sie funktioniert und Sie mit dem Namen zufrieden sind, sollte es keine Rolle spielen, wie sie heißt.
Doc Brown
1
@NicholasKyriakides: Fragen Sie besser einen Ihrer Mitarbeiter (der das System sicherlich besser kennt als ich) nach einem guten Namen, nicht einen Fremden wie mich aus dem Internet.
Doc Brown

Antworten:

6

Der Code, den Sie haben, ist ziemlich gut. Etwas abstoßend erscheint, dass der Initialisierungscode nicht Teil des Objekts selbst ist. Das heißt, Sie können ein Objekt instanziieren, aber wenn Sie vergessen, seine Verdrahtungsklasse aufzurufen, ist es nutzlos.

Stellen Sie sich ein Notification Center (auch bekannt als Event Bus) vor, das ungefähr so ​​definiert ist:

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)
            }
        }
    }
}

Dies ist ein DIY-Multi-Dispatch-Event-Handler. Sie können dann Ihre eigene Verkabelung vornehmen, indem Sie einfach ein NotificationCenter als Konstruktorargument benötigen. Das Senden von Nachrichten und das Warten darauf, dass die Nutzdaten weitergeleitet werden, ist der einzige Kontakt, den Sie mit dem System haben. Daher ist es sehr FEST.

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)
    }
}

Hinweis: Ich habe In-Place-String-Literale verwendet, damit die Schlüssel dem in der Frage verwendeten Stil entsprechen und der Einfachheit halber. Dies ist aufgrund des Risikos von Tippfehlern nicht ratsam. Verwenden Sie stattdessen eine Aufzählung oder Zeichenfolgenkonstanten.

Im obigen Code ist die Symbolleiste dafür verantwortlich, das NotificationCenter über die Art der Ereignisse zu informieren, an denen es interessiert ist, und alle externen Interaktionen über die Benachrichtigungsmethode zu veröffentlichen. Jede andere Klasse, die sich für das interessiert, toolbar-button-click-eventwürde sich einfach in ihrem Konstruktor dafür registrieren.

Interessante Variationen dieses Musters sind:

  • Verwenden mehrerer NCs zur Handhabung verschiedener Teile des Systems
  • Wenn die Benachrichtigungsmethode für jede Benachrichtigung einen Thread erzeugt, anstatt sie seriell zu blockieren
  • Verwenden Sie eine Prioritätsliste anstelle einer regulären Liste innerhalb der NC, um eine teilweise Bestellung zu gewährleisten, bei der Komponenten zuerst benachrichtigt werden
  • Registrieren Sie sich und geben Sie eine ID zurück, mit der Sie sich später abmelden können
  • Überspringen Sie das Nachrichtenargument und senden Sie es einfach basierend auf der Klasse / dem Typ der Nachricht

Interessante Funktionen sind:

  • Die Instrumentierung der NC ist so einfach wie die Registrierung von Loggern zum Drucken von Nutzdaten
  • Das Testen der Interaktion einer oder mehrerer Komponenten besteht lediglich darin, sie zu instanziieren, Listener für die erwarteten Ergebnisse hinzuzufügen und Nachrichten einzusenden
  • Das Hinzufügen neuer Komponenten, die auf alte Nachrichten warten, ist trivial
  • Das Hinzufügen neuer Komponenten, die Nachrichten an alte senden, ist trivial

Interessante Fallstricke und mögliche Abhilfemaßnahmen sind:

  • Ereignisse, die andere Ereignisse auslösen, können verwirrend werden.
    • Fügen Sie dem Ereignis eine Absender-ID hinzu, um die Quelle eines unerwarteten Ereignisses zu ermitteln.
  • Jede Komponente hat keine Ahnung, ob ein bestimmter Teil des Systems betriebsbereit ist, bevor ein Ereignis empfangen wird. Daher können frühe Nachrichten gelöscht werden.
    • Dies kann durch den Code erledigt werden, der die Komponenten erstellt, die eine "System Ready" -Nachricht senden, für die sich natürlich interessierte Komponenten registrieren müssten.
  • Der Ereignisbus erstellt eine implizite Schnittstelle zwischen Komponenten, sodass der Compiler nicht sicher sein kann, dass Sie alles implementiert haben, was Sie sollten.
    • Hier gelten die Standardargumente zwischen statisch und dynamisch.
  • Dieser Ansatz gruppiert Komponenten, nicht unbedingt Verhalten. Das Verfolgen von Ereignissen durch das System erfordert hier möglicherweise mehr Arbeit als der Ansatz des OP. Zum Beispiel könnte OP alle speicherbezogenen Listener zusammen und die löschbezogenen Listener an anderer Stelle zusammen einrichten.
    • Dies kann durch eine gute Ereignisbenennung und Dokumentation wie ein Flussdiagramm gemildert werden. (Ja, die Dokumentation stimmt bekanntermaßen nicht mit dem Code überein). Sie können auch Pre- und Post-Catchall-Handler-Listen hinzufügen, die alle Nachrichten abrufen und ausdrucken, wer was in welcher Reihenfolge gesendet hat.
Joel Harmon
quelle
Dieser Ansatz scheint an die Event-Bus-Architektur zu erinnern. Der einzige Unterschied besteht darin, dass Ihre Registrierung eher eine Themenzeichenfolge als ein Nachrichtentyp ist. Die Hauptschwäche bei der Verwendung von Zeichenfolgen besteht darin, dass sie möglicherweise falsch eingegeben werden. Dies bedeutet, dass die Benachrichtigung oder der Listener falsch geschrieben werden kann und das Debuggen schwierig ist.
Berin Loritsch
Soweit ich das beurteilen kann, ist dies ein klassisches Beispiel für einen Mediator . Ein Problem bei diesem Ansatz besteht darin, dass eine Komponente mit dem Ereignisbus / Mediator gekoppelt wird. Was ist, wenn ich eine Komponente verschieben möchte, z. B. buttonsToolbarin ein anderes Projekt, das keinen Ereignisbus verwendet?
Nik Kyriakides
+1 Der Vorteil des Mediators besteht darin, dass Sie sich gegen Strings / Enums registrieren und die lose Kopplung innerhalb der Klasse haben können. Wenn Sie die Verkabelung außerhalb des Objekts in Ihre Haupt- / Setup-Klasse verschieben, kennt sie alle Objekte und kann sie direkt mit Ereignissen / Funktionen verbinden, ohne sich um die Kopplung kümmern zu müssen. @NicholasKyriakides Wählen Sie das eine oder andere aus, anstatt zu versuchen, beide zu verwenden
Ewan
Bei der klassischen Ereignisbusarchitektur besteht die einzige Kopplung in der Nachricht selbst. Die Nachricht ist normalerweise ein unveränderliches Objekt. Das Objekt, das Nachrichten sendet, benötigt nur die Herausgeberschnittstelle, um die Nachrichten zu senden. Wenn Sie den Typ des Nachrichtenobjekts verwenden, müssen Sie nur das Nachrichtenobjekt veröffentlichen. Wenn Sie eine Zeichenfolge verwenden, müssen Sie sowohl die Themenzeichenfolge als auch die Nachrichtennutzlast angeben (es sei denn, die Zeichenfolge ist die Nutzlast). Die Verwendung von Zeichenfolgen bedeutet, dass Sie die Werte auf beiden Seiten genauestens betrachten müssen.
Berin Loritsch
@NicholasKyriakides Was passiert, wenn Sie Ihren ursprünglichen Code auf eine neue Lösung verschieben? Sie müssen Ihre Setup-Klasse mitbringen und für den neuen Kontext ändern. Gleiches gilt für dieses Muster.
Joel Harmon
3

Früher habe ich einen "Ereignisbus" eingeführt, und in späteren Jahren habe ich mich immer mehr auf das Dokumentobjektmodell selbst verlassen, um Ereignisse für UI-Code zu kommunizieren.

In einem Browser ist das DOM die einzige Abhängigkeit, die immer vorhanden ist - auch beim Laden der Seite. Der Schlüssel besteht darin, benutzerdefinierte Ereignisse in JavaScript zu verwenden und sich auf das Sprudeln von Ereignissen zu verlassen, um diese Ereignisse zu kommunizieren.

Bevor Benutzer vor dem Anhängen von Abonnenten über "Warten auf die Fertigstellung des Dokuments" rufen, document.documentElementverweist die Eigenschaft auf das <html>Element ab dem Zeitpunkt, an dem JavaScript ausgeführt wird, unabhängig davon, wo das Skript importiert wird oder in welcher Reihenfolge es in Ihrem Markup angezeigt wird.

Hier können Sie nach Ereignissen suchen.

Es ist sehr üblich, dass eine JavaScript-Komponente (oder ein Widget) in einem bestimmten HTML-Tag auf der Seite vorhanden ist. Im "root" -Element der Komponente können Sie Ihre Bubbling-Ereignisse auslösen. Abonnenten des <html>Elements erhalten diese Benachrichtigungen wie jedes andere vom Benutzer generierte Ereignis.

Nur ein Beispiel für einen Kesselplattencode:

(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);

So wird das Muster:

  1. Verwenden Sie benutzerdefinierte Ereignisse
  2. Abonnieren Sie diese Ereignisse auf dem document.documentElementGrundstück (Sie müssen nicht warten, bis das Dokument fertig ist).
  3. Veröffentlichen Sie Ereignisse in einem Stammelement für Ihre Komponente oder im document.documentElement.

Dies sollte sowohl für funktionale als auch für objektorientierte Codebasen funktionieren.

Greg Burghardt
quelle
1

Ich verwende denselben Stil bei meiner Videospielentwicklung mit Unity 3D. Ich erstelle Komponenten wie Gesundheit, Eingabe, Statistik, Sound usw. und füge sie einem Spielobjekt hinzu, um das Spielobjekt aufzubauen. Unity verfügt bereits über Mechaniken zum Hinzufügen von Komponenten zu Spielobjekten. Was ich jedoch fand, war, dass fast jeder nach Komponenten fragte oder Komponenten in anderen Komponenten direkt referenzierte (selbst wenn sie Schnittstellen verwendeten, ist es immer noch gekoppelter, danke, dass ich es vorgezogen habe). Ich wollte, dass Komponenten isoliert und ohne Abhängigkeiten von anderen Komponenten erstellt werden können. Ich ließ die Komponenten Ereignisse auslösen, wenn sich Daten änderten (spezifisch für die Komponente), und Methoden deklarieren, um Daten grundsätzlich zu ändern. Dann hat das Spielobjekt, für das ich eine Klasse erstellt habe, alle Komponentenereignisse mit anderen Komponentenmethoden verknüpft.

Das, was ich daran mag, ist, dass ich mir nur diese 1 Klasse ansehen kann, um alle Interaktionen von Komponenten für ein Spielobjekt zu sehen. Es hört sich so an, als ob Ihre Kontaktklasse meinen Spielobjektklassen sehr ähnlich ist (ich benenne Spielobjekte zu dem Objekt, das sie wie MainPlayer, Orc usw. sein sollten).

Diese Klassen sind eine Art Manager-Klassen. Sie selbst haben eigentlich nichts anderes als Instanzen von Komponenten und den Code, um sie anzuschließen. Ich bin mir nicht sicher, warum Sie hier Methoden erstellen, die nur andere Komponentenmethoden aufrufen, wenn Sie sie einfach direkt anschließen könnten. In dieser Klasse geht es eigentlich nur darum, die Veranstaltung zu organisieren.

Als Randnotiz für meine Ereignisregistrierungen habe ich einen Filter-Rückruf und einen Args-Rückruf hinzugefügt. Wenn das Ereignis ausgelöst wird (ich habe meine eigene benutzerdefinierte Ereignisklasse erstellt), ruft es einen Filterrückruf auf, falls einer vorhanden ist, und wenn es true zurückgibt, wechselt es zum args-Rückruf. Der Zweck des Filterrückrufs bestand darin, Flexibilität zu bieten. Ein Ereignis kann aus verschiedenen Gründen ausgelöst werden, aber ich möchte mein angeschlossenes Ereignis nur aufrufen, wenn eine Prüfung wahr ist. Ein Beispiel könnte sein, dass eine Eingabekomponente ein OnKeyHit-Ereignis hat. Wenn ich eine Bewegungskomponente habe, die Methoden wie MoveForward () MoveBackward () usw. enthält, kann ich OnKeyHit + = MoveForward anschließen, aber natürlich möchte ich mit keinem Tastendruck vorwärts gehen. Ich würde es nur tun wollen, wenn der Schlüssel "w" ist. Da OnKeyHit Argumente ausfüllt, die weitergegeben werden sollen, und einer davon der Schlüssel ist, der getroffen wurde,

Für mich sieht das Abonnement für eine bestimmte Game Object Manager-Klasse eher so aus:

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

Da Komponenten isoliert entwickelt werden können, könnten mehrere Programmierer sie codiert haben. Im obigen Beispiel gab der Eingabecodierer dem Argumentobjekt eine Variable mit dem Namen Key. Der Entwickler der Movement-Komponente hat jedoch möglicherweise nicht Key verwendet (wenn er die Argumente überprüfen musste, in diesem Fall wahrscheinlich nicht, aber in anderen Fällen werden die übergebenen Argumentwerte verwendet). Um diese Kommunikationsanforderung zu entfernen, fungiert der args-Rückruf als Zuordnung für die Argumente zwischen Komponenten. Die Person, die diese Spielobjekt-Manager-Klasse erstellt, muss also nur die arg-Variablennamen zwischen den beiden Clients kennen, wenn sie sie verkabeln und an diesem Punkt die Zuordnung durchführen. Diese Methode wird nach der Filterfunktion aufgerufen.

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

In der obigen Situation hat die Eingabe-Person eine Variable innerhalb des args-Objekts 'Key' benannt, aber die Bewegung hat sie 'keyPressed' genannt. Dies hilft dabei, die Isolation zwischen den Komponenten selbst während der Entwicklung zu fördern, und überträgt sie auf den Implementierer der Manager-Klasse, um eine korrekte Verbindung herzustellen.

user441521
quelle
0

Für das, was es wert ist, mache ich etwas als Teil eines Backend-Projekts und habe einen ähnlichen Ansatz gewählt:

  • Mein System enthält keine Widgets (Webkomponenten), sondern abstrakte "Adapter", deren konkrete Implementierungen unterschiedliche Protokolle verarbeiten.
  • Ein Protokoll wird als eine Reihe möglicher "Konversationen" modelliert. Der Protokolladapter löst diese Konversationen abhängig von einem eingehenden Ereignis aus.
  • Es gibt einen Ereignisbus, der im Grunde ein Empfangsthema ist.
  • Der Ereignisbus abonniert die Ausgabe aller Adapter, und alle Adapter abonnieren die Ausgabe des Ereignisbusses.
  • Der "Adapter" wird als Gesamtstrom aller seiner "Konversationen" modelliert. Eine Konversation ist ein Stream, der die Ausgabe des Ereignisbusses abonniert und Nachrichten an den Ereignisbus generiert, die von einer Zustandsmaschine gesteuert werden.

Wie ich mit Ihren Konstruktions- / Verkabelungsherausforderungen umgegangen bin:

  • Ein Protokoll (vom Adapter implementiert) definiert Konversationsinitiierungskriterien als Filter für den Eingabestream, den es abonniert hat. In C # sind dies LINQ-Abfragen über Streams. In ReactJS wären dies .Where- oder .Filter-Operatoren.
  • Eine Konversation entscheidet mithilfe eigener Filter, was eine relevante Nachricht ist.
  • Im Allgemeinen ist alles, was den Bus abonniert hat, ein Stream, und der Bus ist für diese Streams abonniert.

Die Analogie zu Ihrer Symbolleiste:

  • Die Symbolleistenklasse ist eine .Map einer beobachtbaren Eingabe (des Busses), die eine Beobachtung von Symbolleistenereignissen ist, für die der Bus abonniert ist
  • Ein Observable von Symbolleisten (wenn Sie mehrere Unter-Symbolleisten haben) bedeutet, dass Sie möglicherweise mehrere Observables haben, sodass Ihre Symbolleiste ein Observable von Observables ist. Dies wären RxJs, die zu einem einzigen Ausgang des Busses zusammengeführt werden.

Probleme, mit denen Sie möglicherweise konfrontiert sind:

  • Stellen Sie sicher, dass Ereignisse nicht zyklisch sind, und hängen Sie den Prozess auf.
  • Parallelität (weiß nicht, ob dies für WebComponents relevant ist): Bei asynchronen Vorgängen oder Vorgängen, die möglicherweise lange ausgeführt werden, blockiert Ihr Ereignishandler möglicherweise den beobachtbaren Thread, wenn er nicht als Hintergrundaufgabe ausgeführt wird. RxJS-Scheduler können dies beheben (standardmäßig können Sie beispielsweise einen Standard-Scheduler für alle Busabonnements verwenden).
  • Komplexere Szenarien, die nicht ohne eine Vorstellung von einer Konversation modelliert werden können (z. B. Behandeln eines Ereignisses durch Senden einer Nachricht und Warten auf eine Antwort, die selbst ein Ereignis ist). In diesem Fall ist eine Zustandsmaschine hilfreich, um dynamisch anzugeben, welche Ereignisse behandelt werden sollen (Konversation in meinem Modell). Ich mache das, indem ich den Konversationsstrom .filternach Status habe (tatsächlich ist die Implementierung funktionaler - die Konversation ist eine flache Karte von Observablen aus einem Observablen von Zustandsänderungsereignissen).

Zusammenfassend können Sie also Ihre gesamte Problemdomäne als Observable oder 'funktional' / 'deklarativ' betrachten und Ihre Webkomponenten als Ereignisströme betrachten, als Observables, die vom Bus (einem Observable) abgeleitet sind, zu dem der Bus (an Beobachter) ist ebenfalls abonniert. Die Instanziierung von Observablen (z. B. eine neue Symbolleiste) ist insofern deklarativ, als der gesamte Prozess als Observable von Observablen .map'd aus dem Eingabestream angesehen werden kann.

Wächter
quelle