Reaktivitätsmodelle erklärt
Es ist (schon) 10 Jahre her, seit ich mit der Entwicklung von Anwendungen und Websites begonnen habe, aber das JavaScript-Ökosystem war noch nie so spannend wie heute!
Im Jahr 2022 war die Community vom Konzept „Signal“ so fasziniert, dass die meisten JavaScript-Frameworks sie in ihre eigene Engine integrierten. Ich denke an Preact, das seit September 2022 vom Komponentenlebenszyklus entkoppelte reaktive Variablen anbietet; oder in jüngerer Zeit Angular, das Signale im Mai 2023 experimentell implementierte, dann offiziell ab Version 18. Auch andere JavaScript-Bibliotheken haben beschlossen, ihren Ansatz zu überdenken...
Von 2023 bis heute habe ich Signale konsequent in verschiedenen Projekten verwendet. Ihre einfache Implementierung und Nutzung hat mich voll und ganz überzeugt, sodass ich ihre Vorteile in technischen Workshops, Schulungen und Konferenzen mit meinem beruflichen Netzwerk geteilt habe.
Aber in letzter Zeit begann ich mich zu fragen, ob dieses Konzept wirklich „revolutionär“ war/ob es Alternativen zu Signals gibt? Also habe ich mich eingehender mit dieser Überlegung befasst und verschiedene Ansätze für reaktive Systeme entdeckt.
Dieser Beitrag gibt einen Überblick über verschiedene Reaktivitätsmodelle und mein Verständnis ihrer Funktionsweise.
Hinweis: An dieser Stelle, Sie haben es wahrscheinlich erraten, werde ich nicht über Javas „Reaktive Streams“ sprechen; andernfalls hätte ich diesen Beitrag mit „WTF ist Gegendruck!?“ betitelt. ?
Wenn wir über Reaktivitätsmodelle sprechen, sprechen wir (in erster Linie) von „reaktiver Programmierung“, insbesondere aber von „Reaktivität“.
Die reaktive Programmierung ist ein Entwicklungsparadigma, das es ermöglicht, die Änderung einer Datenquelle automatisch an Verbraucher weiterzugeben.
Also können wir die Reaktivität als die Fähigkeit definieren, Abhängigkeiten in Echtzeit abhängig von der Datenänderung zu aktualisieren.
Hinweis: Kurz gesagt, wenn ein Benutzer ein Formular ausfüllt und/oder absendet, müssen wir auf diese Änderungen reagieren, eine Ladekomponente oder irgendetwas anderes anzeigen, das angibt, dass etwas passiert. .. Ein weiteres Beispiel: Beim asynchronen Empfang von Daten müssen wir reagieren, indem wir alle oder einen Teil dieser Daten anzeigen, eine neue Aktion ausführen usw.
In diesem Zusammenhang stellen reaktive Bibliotheken Variablen bereit, die automatisch aktualisiert und effizient weitergegeben werden, wodurch es einfacher wird, einfachen und optimierten Code zu schreiben.
Um effizient zu sein, müssen diese Systeme diese Variablen genau dann neu berechnen/auswerten, wenn sich ihre Werte geändert haben! Um sicherzustellen, dass die übertragenen Daten konsistent und aktuell bleiben, muss das System die Anzeige von Zwischenzuständen vermeiden (insbesondere während der Berechnung von Zustandsänderungen).
Hinweis: Der Status bezieht sich auf die Daten/Werte, die während der gesamten Lebensdauer eines Programms/einer Anwendung verwendet werden.
Okay, aber dann... Was genau sind diese „Reaktivitätsmodelle“?
Das erste Reaktivitätsmodell heißt „PUSH“ (oder „eifrige“ Reaktivität). Dieses System basiert auf den folgenden Prinzipien:
Wie Sie vielleicht schon erraten haben, basiert das „PUSH“-Modell auf dem Designmuster „Observable/Observer“.
Betrachten wir den folgenden Ausgangszustand
let a = { firstName: "John", lastName: "Doe" }; const b = a.firstName; const c = a.lastName; const d = `${b} ${c}`;
Bei Verwendung einer reaktiven Bibliothek (wie RxJS) würde dieser Anfangszustand eher so aussehen:
let a = observable.of({ firstName: "John", lastName: "Doe" }); const b = a.pipe(map((a) => a.firstName)); const c = a.pipe(map((a) => a.lastName)); const d = merge(b, c).pipe(reduce((b, c) => `${b} ${c}`));
Hinweis: Für diesen Beitrag sollten alle Codeschnipsel als „Pseudocode“ betrachtet werden.
Nehmen wir nun an, dass ein Verbraucher (zum Beispiel eine Komponente) den Wert von Zustand D protokollieren möchte, wann immer diese Datenquelle aktualisiert wird,
d.subscribe((value) => console.log(value));
Unsere Komponente würde den Datenstrom abonnieren; es muss noch eine Veränderung auslösen,
a.next({ firstName: "Jane", lastName: "Doe" });
Von dort aus erkennt das „PUSH“-System die Änderung und sendet sie automatisch an die Verbraucher. Basierend auf dem oben genannten Ausgangszustand finden Sie hier eine Beschreibung der Vorgänge, die auftreten können:
Eine der Herausforderungen dieses Systems liegt in der Reihenfolge der Berechnungen. Basierend auf unserem Anwendungsfall werden Sie tatsächlich feststellen, dass D möglicherweise zweimal ausgewertet wird: ein erstes Mal mit dem Wert von C in seinem vorherigen Zustand; und ein zweites Mal mit dem aktuellen C-Wert! In dieser Art von Reaktivitätsmodell wird diese Herausforderung als „Diamantproblem“ ♦️ bezeichnet.
Nehmen wir nun an, dass sich der Staat auf zwei Hauptdatenquellen verlässt,
let a = { firstName: "John", lastName: "Doe" }; const b = a.firstName; const c = a.lastName; const d = `${b} ${c}`;
Beim Aktualisieren von E berechnet das System den gesamten Status neu, wodurch eine einzige Quelle der Wahrheit erhalten bleibt, indem der vorherige Status überschrieben wird.
Wieder einmal tritt das „Diamantproblem“ auf... Dieses Mal auf der Datenquelle C, die möglicherweise zweimal ausgewertet wird, und zwar immer auf D.
Das „Diamantenproblem“ ist keine neue Herausforderung im „eifrigen“ Reaktivitätsmodell. Einige Berechnungsalgorithmen (insbesondere die von MobX verwendeten) können die „Knoten des reaktiven Abhängigkeitsbaums“ markieren, um die Zustandsberechnung auszugleichen. Bei diesem Ansatz würde das System zuerst die „Root“-Datenquellen (A und E in unserem Beispiel), dann B und C und schließlich D auswerten. Eine Änderung der Reihenfolge der Zustandsberechnungen hilft, diese Art von Problem zu beheben.
Das zweite Reaktivitätsmodell heißt "PULL". Im Gegensatz zum „PUSH“-Modell basiert es auf den folgenden Prinzipien:
Diese letzte Regel ist am wichtigsten, die Sie sich merken sollten: Im Gegensatz zum vorherigen System verschiebt diese letzte Regel die Zustandsberechnung, um mehrere Auswertungen derselben Datenquelle zu vermeiden.
Behalten wir den vorherigen Ausgangszustand bei...
In einem solchen System hätte die Anfangszustandssyntax die folgende Form:
let a = observable.of({ firstName: "John", lastName: "Doe" }); const b = a.pipe(map((a) => a.firstName)); const c = a.pipe(map((a) => a.lastName)); const d = merge(b, c).pipe(reduce((b, c) => `${b} ${c}`));
Hinweis:React-Enthusiasten werden diese Syntax wahrscheinlich erkennen ?
Durch die Deklaration einer reaktiven Variablen entsteht ein Tupel: unveränderliche Variable auf der einen Seite; Aktualisierungsfunktion dieser Variablen andererseits. Die übrigen Anweisungen (in unserem Fall B, C und D) werden als abgeleitete Zustände betrachtet, da sie auf ihre jeweiligen Abhängigkeiten „lauschen“.
d.subscribe((value) => console.log(value));
Das entscheidende Merkmal eines „faulen“ Systems ist, dass es Änderungen nicht sofort weitergibt, sondern nur, wenn dies ausdrücklich angefordert wird.
let a = { firstName: "John", lastName: "Doe" }; const b = a.firstName; const c = a.lastName; const d = `${b} ${c}`;
In einem „PULL“-Modell löst die Verwendung eines effect() (von einer Komponente) zum Protokollieren des Werts einer reaktiven Variablen (angegeben als Abhängigkeit) die Berechnung der Zustandsänderung aus:
Eine Optimierung dieses Systems ist bei der Abfrage von Abhängigkeiten möglich. Tatsächlich wird A im obigen Szenario zweimal abgefragt, um festzustellen, ob es aktualisiert wurde. Die erste Abfrage könnte jedoch ausreichen, um festzustellen, ob sich der Status geändert hat. C müsste diese Aktion nicht ausführen... Stattdessen könnte A nur seinen Wert senden.
Komplizieren wir den Zustand etwas, indem wir eine zweite reaktive Variable „root“ hinzufügen.
let a = observable.of({ firstName: "John", lastName: "Doe" }); const b = a.pipe(map((a) => a.firstName)); const c = a.pipe(map((a) => a.lastName)); const d = merge(b, c).pipe(reduce((b, c) => `${b} ${c}`));
Noch einmal verschiebt das System die Zustandsberechnung, bis sie explizit angefordert wird. Mit dem gleichen Effekt wie zuvor löst das Aktualisieren einer neuen reaktiven Variablen die folgenden Schritte aus:
Da sich der Wert von A nicht geändert hat, ist eine Neuberechnung dieser Variablen nicht erforderlich (dasselbe gilt für den Wert von B). In solchen Fällen verbessert die Verwendung von Memoisierungsalgorithmen die Leistung während der Zustandsberechnung.
Das letzte Reaktivitätsmodell ist das „PUSH-PULL“-System. Der Begriff „PUSH“ spiegelt die sofortige Weitergabe von Änderungsbenachrichtigungen wider, während „PULL“ sich auf das Abrufen der Statuswerte bei Bedarf bezieht. Dieser Ansatz steht in engem Zusammenhang mit der sogenannten „feinkörnigen“ Reaktivität, die den folgenden Prinzipien folgt:
Beachten Sie, dass diese Art der Reaktivität nicht nur beim „PUSH-PULL“-Modell auftritt. Unter feinkörniger Reaktivität versteht man die präzise Verfolgung von Systemabhängigkeiten. Es gibt also PUSH und PULL Reaktivitätsmodelle, die ebenfalls auf diese Weise funktionieren (ich denke an Jotai oder Recoil.
).Immer noch basierend auf dem vorherigen Anfangszustand... Die Deklaration eines Anfangszustandes in einem „feinkörnigen“ Reaktivitätssystem würde wie folgt aussehen:
let a = { firstName: "John", lastName: "Doe" }; const b = a.firstName; const c = a.lastName; const d = `${b} ${c}`;
Hinweis: Die Verwendung des Signalschlüsselworts ist hier nicht nur anekdotisch ?
In Bezug auf die Syntax ist es dem „PUSH“-Modell sehr ähnlich, es gibt jedoch einen bemerkenswerten und wichtigen Unterschied: Abhängigkeiten! In einem „feinkörnigen“ Reaktivitätssystem ist es nicht notwendig, die zur Berechnung eines abgeleiteten Zustands erforderlichen Abhängigkeiten explizit zu deklarieren, da diese Zustände implizit die von ihnen verwendeten Variablen verfolgen. In unserem Fall verfolgen B und C automatisch Änderungen am Wert von A und D verfolgt Änderungen sowohl an B als auch an C.
let a = observable.of({ firstName: "John", lastName: "Doe" }); const b = a.pipe(map((a) => a.firstName)); const c = a.pipe(map((a) => a.lastName)); const d = merge(b, c).pipe(reduce((b, c) => `${b} ${c}`));
In einem solchen System ist die Aktualisierung einer reaktiven Variablen effizienter als in einem einfachen „PUSH“-Modell, da die Änderung automatisch an die davon abhängigen abgeleiteten Variablen weitergegeben wird (nur als Benachrichtigung, nicht). der Wert selbst).
d.subscribe((value) => console.log(value));
Dann werden bei Bedarf (nehmen wir das Beispiel Logger) durch die Verwendung von D innerhalb des Systems die Werte der zugehörigen Wurzelzustände (in unserem Fall A) abgerufen und die Werte berechnet der abgeleiteten Zustände (B und C) und schließlich D auswerten. Ist das nicht eine intuitive Funktionsweise?
Betrachten wir den folgenden Zustand:
a.next({ firstName: "Jane", lastName: "Doe" });
Auch hier ermöglicht der „feinkörnige“ Aspekt des PUSH-PULL-Systems die automatische Verfolgung jedes Zustands. Der abgeleitete Zustand C verfolgt nun die Wurzelzustände A und E. Durch das Aktualisieren der Variablen E werden die folgenden Aktionen ausgelöst:
Dies ist die vorherige Verknüpfung reaktiver Abhängigkeiten miteinander, die dieses Modell so effizient macht!
Tatsächlich wird das Framework in einem klassischen „PULL“-System (wie z. B. dem Virtual DOM von React) beim Aktualisieren eines reaktiven Status von einer Komponente über die Änderung benachrichtigt (wodurch ein „ diffing"-Phase). Dann berechnet das Framework bei Bedarf (und verzögert) die Änderungen, indem es den reaktiven Abhängigkeitsbaum durchläuft. jedes Mal, wenn eine Variable aktualisiert wird! Diese „Entdeckung“ des Zustands der Abhängigkeiten ist mit erheblichen Kosten verbunden...
Bei einem „feinkörnigen“ Reaktivitätssystem (wie Signalen) benachrichtigt die Aktualisierung reaktiver Variablen/Primitive automatisch jeden damit verknüpften abgeleiteten Zustand über die Änderung. Daher besteht keine Notwendigkeit, die damit verbundenen Abhängigkeiten (wieder) zu entdecken; Die Staatsverbreitung ist gezielt!
Im Jahr 2024 haben sich die meisten Web-Frameworks entschieden, ihre Funktionsweise zu überdenken, insbesondere im Hinblick auf ihr Reaktivitätsmodell. Dieser Wandel hat sie im Allgemeinen effizienter und wettbewerbsfähiger gemacht. Andere entscheiden sich dafür, (noch) hybrid zu sein (ich denke hier an Vue), was sie in vielen Situationen flexibler macht.
Abschließend, unabhängig vom gewählten Modell, basiert meiner Meinung nach ein (gutes) reaktives System auf ein paar Hauptregeln:
Dieser letzte Punkt, der als grundlegendes Prinzip der deklarativen Programmierung interpretiert werden kann, ist, dass ein (gutes) reaktives System meiner Meinung nach deterministisch sein muss! Dies ist der „Determinismus“, der ein reaktives Modell unabhängig von der Komplexität des Algorithmus zuverlässig, vorhersehbar und einfach in technischen Projekten im großen Maßstab einsetzbar macht.
Das obige ist der detaillierte Inhalt vonWTF ist Reaktivität!?. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!