JavaScript-Reaktivität: Ein einfaches und intuitives Beispiel

Viele JavaScript-Front-End-Frameworks (wie Angular, React und Vue) verfügen über eigene Reaktivitätssysteme. Das Verständnis der Funktionen dieser Systeme ist für jeden Entwickler hilfreich und hilft ihm, moderne JS-Frameworks effizienter zu nutzen.



Das Material, dessen Übersetzung wir heute veröffentlichen, zeigt ein schrittweises Beispiel für die Entwicklung eines Reaktivitätssystems in reinem JavaScript. Dieses System implementiert dieselben Mechanismen, die in Vue verwendet werden.

Reaktivitätssystem


Für jemanden, der zum ersten Mal auf das Vue-Reaktivitätssystem stößt, mag es wie eine mysteriöse Black Box erscheinen. Betrachten Sie eine einfache Vue-Anwendung. Hier ist das Markup:

<div id="app">    <div>Price: ${{ price }}</div>    <div>Total: ${{ price*quantity }}</div>    <div>Taxes: ${{ totalPriceWithTax }}</div> </div> 

Hier ist der Framework-Verbindungsbefehl und der Anwendungscode.

 <script src="https://cdn.jsdelivr.net/npm/vue"></script> <script>   var vm = new Vue({       el: '#app',       data: {           price: 5.00,           quantity: 2       },       computed: {           totalPriceWithTax() {               return this.price * this.quantity * 1.03           }       }   }) </script> 

Irgendwie findet Vue heraus, dass der Motor drei Dinge tun muss, wenn sich der price ändert:

  1. Aktualisieren Sie den price auf der Webseite.
  2. Berechnen Sie den Ausdruck, in dem der price mit der quantity multipliziert wird, neu und zeigen Sie den resultierenden Wert auf der Seite an.
  3. Rufen Sie die Funktion totalPriceWithTax und setzen Sie erneut das, was sie zurückgibt, auf die Seite.

Was hier passiert, ist in der folgenden Abbildung dargestellt.


Woher weiß Vue, was zu tun ist, wenn sich die Preiseigenschaft ändert?

Jetzt haben wir Fragen dazu, wie Vue weiß, was genau aktualisiert werden muss, wenn sich der price ändert, und wie die Engine verfolgt, was auf der Seite passiert. Was Sie hier beobachten können, sieht nicht wie eine normale JS-Anwendung aus.

Vielleicht ist dies noch nicht offensichtlich, aber das Hauptproblem, das wir hier lösen müssen, ist, dass JS-Programme normalerweise nicht so funktionieren. Führen Sie beispielsweise den folgenden Code aus:

 let price = 5 let quantity = 2 let total = price * quantity //  10 price = 20; console.log(`total is ${total}`) 

Was wird Ihrer Meinung nach in der Konsole angezeigt? Da hier außer regulärem JS nichts verwendet wird, gelangt 10 zur Konsole.


Das Ergebnis des Programms

Und wenn wir die Funktionen von Vue verwenden, können wir in einer ähnlichen Situation ein Szenario implementieren, in dem der total nachgezählt wird, wenn sich die price oder quantity ändern. Das heißt, wenn das Reaktivitätssystem bei der Ausführung des obigen Codes verwendet würde, würden nicht 10, sondern 40 auf der Konsole angezeigt:


Konsolenausgabe, die durch hypothetischen Code unter Verwendung eines Reaktivitätssystems generiert wird

JavaScript ist eine Sprache, die sowohl prozedural als auch objektorientiert funktionieren kann, jedoch kein integriertes Reaktivitätssystem enthält. Daher gibt der Code, den wir bei der price berücksichtigt haben, die Nummer 40 nicht an die Konsole aus. Damit der total neu berechnet werden kann, wenn sich der price oder die quantity ändert, müssen wir selbst ein Reaktivitätssystem erstellen und damit das gewünschte Verhalten erreichen. Wir werden den Weg zu diesem Ziel in mehrere kleine Schritte unterteilen.

Aufgabe: Speicherung von Regeln zur Berechnung von Indikatoren


Wir müssen irgendwo Informationen darüber speichern, wie der total berechnet wird, damit wir ihn neu berechnen können, wenn wir die Werte der price oder quantity ändern.

▍Lösung


Zuerst müssen wir der Anwendung Folgendes mitteilen: "Hier ist der Code, den ich ausführen werde. Speichern Sie ihn. Möglicherweise muss ich ihn ein anderes Mal ausführen." Dann müssen wir den Code ausführen. Wenn sich später die price oder quantity geändert haben, müssen Sie den gespeicherten Code aufrufen, um die total neu zu berechnen. Es sieht so aus:


Der gesamte Berechnungscode muss irgendwo gespeichert werden, um später darauf zugreifen zu können

Der Code, den Sie in JavaScript aufrufen können, um eine Aktion auszuführen, ist als Funktion formatiert. Daher werden wir eine Funktion schreiben, die sich mit der Berechnung der total , und einen Mechanismus zum Speichern von Funktionen erstellen, die wir später möglicherweise benötigen.

 let price = 5 let quantity = 2 let total = 0 let target = null target = function () {   total = price * quantity } record() //       ,       target() //   

Beachten Sie, dass wir die anonyme Funktion in der target speichern und dann die record aufrufen. Wir werden weiter unten darüber sprechen. Ich möchte auch darauf hinweisen, dass die target unter Verwendung der Syntax der ES6-Pfeilfunktionen wie folgt umgeschrieben werden kann:

 target = () => { total = price * quantity } 

Hier ist die Deklaration der record und der Datenstruktur, die zum Speichern der Funktionen verwendet wird:

 let storage = [] //     target function record () { // target = () => { total = price * quantity }   storage.push(target) } 

Mit der record speichern wir die target (in unserem Fall { total = price * quantity } ) im storage , wodurch wir diese Funktion später aufrufen können, möglicherweise mit der replay , deren Code unten gezeigt wird. Dadurch können wir alle im storage gespeicherten Funktionen aufrufen.

 function replay () {   storage.forEach(run => run()) } 

Hier gehen wir alle anonymen Funktionen durch, die im storage gespeichert sind, und führen jede von ihnen aus.

Dann können wir in unserem Code Folgendes tun:

 price = 20 console.log(total) // 10 replay() console.log(total) // 40 

Es sieht nicht alles so schwer aus, oder? Hier ist der gesamte Code, dessen Fragmente wir oben besprochen haben, falls es für Sie bequemer ist, sich endlich damit zu befassen. Dieser Code ist übrigens nicht versehentlich so geschrieben.

 let price = 5 let quantity = 2 let total = 0 let target = null let storage = [] function record () {   storage.push(target) } function replay () {   storage.forEach(run => run()) } target = () => { total = price * quantity } record() target() price = 20 console.log(total) // 10 replay() console.log(total) // 40 

Dies wird nach dem Start in der Browserkonsole angezeigt.


Code Ergebnis

Herausforderung: eine zuverlässige Lösung zum Speichern von Funktionen


Wir können die benötigten Funktionen bei Bedarf weiter aufschreiben, aber es wäre schön, wenn wir eine zuverlässigere Lösung hätten, die mit der Anwendung skaliert werden kann. Vielleicht ist es eine Klasse, die eine Liste von Funktionen verwaltet, die ursprünglich in die Zielvariable geschrieben wurden, und die Benachrichtigungen erhält, wenn wir diese Funktionen erneut ausführen müssen.

▍Lösung: Abhängigkeitsklasse


Ein Ansatz zur Lösung des oben genannten Problems besteht darin, das von uns benötigte Verhalten in einer Klasse zu kapseln, die als Abhängigkeit bezeichnet werden kann. Diese Klasse implementiert das Standard-Beobachter-Programmiermuster.

Wenn wir eine JS-Klasse erstellen, die zum Verwalten unserer Abhängigkeiten verwendet wird (was der Implementierung ähnlicher Mechanismen in Vue nahe kommt), sieht dies möglicherweise folgendermaßen aus:

 class Dep { // Dep -    Dependency   constructor () {       this.subscribers = [] //  ,                               //    notify()   }   depend () { //   record       if (target && !this.subscribers.includes(target)){           //    target                //                this.subscribers.push(target)       }   }   notify () { //   replay       this.subscribers.forEach(sub => sub())       //  -     } } 

Bitte beachten Sie, dass wir anstelle des storage jetzt unsere anonymen Funktionen im subscribers speichern. Anstelle der record wird jetzt die depend Methode aufgerufen. Auch hier wird anstelle der replay die notify . So führen Sie unseren Code mit der Dep Klasse aus:

 const dep = new Dep() let price = 5 let quantity = 2 let total = 0 let target = () => { total = price * quantity } dep.depend() //   target    target() //     total console.log(total) // 10 -   price = 20 console.log(total) // 10 -    ,    dep.notify() //   -  console.log(total) // 40 -    

Unser neuer Code funktioniert genauso wie zuvor, aber jetzt ist er besser gestaltet und fühlt sich besser für die Wiederverwendung an.

Das einzige, was bisher seltsam erscheint, ist die Arbeit mit einer in der target gespeicherten Funktion.

Aufgabe: Mechanismus zum Erstellen anonymer Funktionen


In Zukunft müssen wir für jede Variable ein Objekt der Klasse Dep erstellen. Darüber hinaus wäre es schön, das Verhalten beim Erstellen anonymer Funktionen irgendwo zusammenzufassen, die beim Aktualisieren der relevanten Daten aufgerufen werden sollten. Vielleicht hilft uns dies bei einer zusätzlichen Funktion, die wir als watcher . Dies führt dazu, dass wir diese Konstruktion aus dem vorherigen Beispiel durch eine neue Funktion ersetzen können:

 let target = () => { total = price * quantity } dep.depend() target() 

Tatsächlich sieht ein Aufruf der watcher Funktion, die diesen Code ersetzt, folgendermaßen aus:

 watcher(() => {   total = price * quantity }) 

▍ Lösung: Überwachungsfunktion


Innerhalb der watcher Funktion, deren Code unten dargestellt ist, können wir mehrere einfache Aktionen ausführen:

 function watcher(myFunc) {   target = myFunc //   target   myFunc   dep.depend() //  target      target() //     target = null //   target } 

Wie Sie sehen können, verwendet die watcher Funktion als Argument die myFunc Funktion, schreibt sie in die globale Zielvariable, ruft dep.depend() auf, um diese Funktion zur Liste der Abonnenten hinzuzufügen, ruft diese Funktion auf und setzt die Zielvariable zurück.
Jetzt erhalten wir alle die gleichen Werte 10 und 40, wenn wir den folgenden Code ausführen:

 price = 20 console.log(total) dep.notify() console.log(total) 

Vielleicht fragen Sie sich, warum wir target als globale Variable implementiert haben, anstatt diese Variable bei Bedarf an unsere Funktionen zu übergeben. Wir haben gute Gründe, genau das zu tun, später werden Sie verstehen.

Aufgabe: eigenes Dep-Objekt für jede Variable


Wir haben ein einzelnes Objekt der Klasse Dep . Was ist, wenn jede unserer Variablen ein eigenes Dep Klassenobjekt haben muss? Bevor wir fortfahren, verschieben wir die Daten, mit denen wir arbeiten, in die Eigenschaften des Objekts:

 let data = { price: 5, quantity: 2 } 

Stellen Sie sich für einen Moment vor, dass jede unserer Eigenschaften ( price und quantity ) ein eigenes internes Dep Klassenobjekt hat.


Preis- und Mengeneigenschaften

Jetzt können wir die watcher Funktion folgendermaßen aufrufen:

 watcher(() => {   total = data.price * data.quantity }) 

Da wir hier mit dem Wert der Eigenschaft data.price , benötigen wir das Dep Klassenobjekt der Eigenschaft price , um eine anonyme Funktion (im target gespeichert) in ihrem Abonnentenarray zu platzieren (durch Aufrufen von dep.depend() ). Da wir hier mit data.quantity , benötigen wir außerdem das Dep Objekt der quantity Eigenschaft, um eine anonyme Funktion (wieder im target gespeichert) in ihrem Abonnenten-Array zu platzieren.

Wenn Sie dies in Form eines Diagramms darstellen, erhalten Sie Folgendes.


Funktionen fallen in Abonnentenarrays von Dep-Klassenobjekten, die unterschiedlichen Eigenschaften entsprechen

Wenn wir eine weitere anonyme Funktion haben, in der wir nur mit der Eigenschaft data.price , sollte die entsprechende anonyme Funktion nur in das Depot des Objekts dieser Eigenschaft gehen.


Zusätzliche Beobachter können nur zu einer der verfügbaren Eigenschaften hinzugefügt werden.

Wann müssen Sie möglicherweise dep.notify() für Funktionen aufrufen, die Änderungen an der price Eigenschaft abonniert haben? Dies wird bei price . Dies bedeutet, dass der folgende Code für uns funktionieren sollte, wenn unser Beispiel vollständig fertig ist.


Wenn Sie den Preis ändern, müssen Sie hier dep.notify () für alle Funktionen aufrufen, die den Preis ändern

Damit alles auf diese Weise funktioniert, benötigen wir eine Möglichkeit, Ereignisse beim Zugriff auf Immobilien abzufangen (in unserem Fall handelt es sich um price oder quantity ). Auf diese Weise können Sie in diesem Fall die target in einem Array von Abonnenten speichern und bei Änderung der entsprechenden Variablen die in diesem Array gespeicherte Funktion ausführen.

▍Lösung: Object.defineProperty ()


Jetzt müssen wir uns mit der Standard-ES5-Methode Object.defineProperty () vertraut machen. Sie können den Eigenschaften von Objekten Getter und Setter zuweisen. Lassen Sie mich, bevor wir zu ihrer praktischen Anwendung übergehen, die Funktionsweise dieser Mechanismen anhand eines einfachen Beispiels demonstrieren.

 let data = { price: 5, quantity: 2 } Object.defineProperty(data, 'price', { //       price   get() { //        console.log(`I was accessed`)   },   set(newVal) { //        console.log(`I was changed`)   } }) data.price //       data.price = 20 //      

Wenn Sie diesen Code in der Browserkonsole ausführen, wird der folgende Text angezeigt.


Getter und Setter Ergebnisse

Wie Sie sehen können, druckt unser Beispiel einfach ein paar Textzeilen auf die Konsole. Es werden jedoch keine Werte gelesen oder festgelegt, da wir die Standardfunktionalität von Gettern und Setzern neu definiert haben. Wir werden die Funktionalität dieser Methoden wiederherstellen. Es wird nämlich erwartet, dass Getter die Werte der entsprechenden Methoden zurückgeben und Setter sie setzen. Daher fügen wir dem Code eine neue Variable, internalValue , hinzu, mit der wir den aktuellen price speichern.

 let data = { price: 5, quantity: 2 } let internalValue = data.price //   Object.defineProperty(data, 'price', { //       price   get() { //        console.log(`Getting price: ${internalValue}`)       return internalValue   },   set(newVal) {       console.log(`Setting price to: ${newVal}`)       internalValue = newVal   } }) total = data.price * data.quantity //       data.price = 20 //      

Nun, da Getter und Setter so arbeiten, wie sie funktionieren sollten, was wird Ihrer Meinung nach in die Konsole gelangen, wenn dieser Code ausgeführt wird? Schauen Sie sich die folgende Abbildung an.


Datenausgabe an die Konsole

Jetzt haben wir einen Mechanismus, mit dem Sie Benachrichtigungen erhalten können, wenn Sie Eigenschaftswerte lesen und wenn neue Werte in sie geschrieben werden. Nachdem wir den Code ein wenig überarbeitet haben, können wir Getter und Setter mit allen Eigenschaften des data ausstatten. Hier verwenden wir die Object.keys() -Methode, die ein Array von Schlüsseln des an sie übergebenen Objekts zurückgibt.

 let data = { price: 5, quantity: 2 } Object.keys(data).forEach(key => { //        data   let internalValue = data[key]   Object.defineProperty(data, key, {       get() {           console.log(`Getting ${key}: ${internalValue}`)           return internalValue       },       set(newVal) {           console.log(`Setting ${key} to: ${newVal}`)           internalValue = newVal       }   }) }) let total = data.price * data.quantity data.price = 20 

Jetzt haben alle Eigenschaften des data Getter und Setter. Dies wird in der Konsole angezeigt, nachdem dieser Code ausgeführt wurde.


Datenausgabe an die Konsole durch Getter und Setter

Montage des Reaktivitätssystems


Wenn ein Codefragment wie total = data.price * data.quantity und der Wert der total = data.price * data.quantity die entsprechende anonyme Funktion (in unserem Fall das Ziel) "merken". Wenn die price geändert, dh auf einen neuen Wert gesetzt wird, wird diese Funktion aufgerufen, um die von ihr ausgeführten Vorgänge zu wiederholen, da bekannt ist, dass eine bestimmte Codezeile davon abhängt. Infolgedessen können die in Gettern und Setzern ausgeführten Operationen wie folgt vorgestellt werden:

  • Getter - Sie müssen sich an die anonyme Funktion erinnern, die wir erneut aufrufen, wenn sich der Wert ändert.
  • Setter - Die gespeicherte anonyme Funktion muss ausgeführt werden, was zu einer Änderung des entsprechenden resultierenden Werts führt.

Wenn Sie die Dep Klasse verwenden, die Ihnen in dieser Beschreibung bereits bekannt ist, erhalten Sie Folgendes:

  • Beim Lesen eines Eigenschaftswerts wird dep.depend() aufgerufen, um die aktuelle target zu speichern.
  • Wenn ein Wert in eine Eigenschaft geschrieben wird, wird dep.notify() aufgerufen, um alle gespeicherten Funktionen neu zu starten.

Jetzt werden wir diese beiden Ideen kombinieren und schließlich zu dem Code kommen, mit dem wir unser Ziel erreichen können.

 let data = { price: 5, quantity: 2 } let target = null //  -    ,     class Dep {   constructor () {       this.subscribers = []   }   depend () {       if (target && !this.subscribers.includes(target)){           this.subscribers.push(target)       }   }   notify () {       this.subscribers.forEach(sub => sub())   } } //      ,  //      Object.keys(data).forEach(key => {   let internalValue = data[key]   //         //   Dep   const dep = new Dep()   Object.defineProperty(data, key, {       get() {           dep.depend() //    target           return internalValue       },       set(newVal) {           internalValue = newVal           dep.notify() //           }   }) }) //   watcher   dep.depend(), //        function watcher(myFunc){   target = myFunc   target()   target = null } watcher(() => {   data.total = data.price * data.quantity }) 

Experimentieren wir mit diesem Code in der Browserkonsole.


Bereit Code-Experimente

Wie Sie sehen, funktioniert es genau so, wie wir es brauchen! Die price und quantity sind reaktiv geworden! Der gesamte Code, der für die Generierung der total wenn sich price oder quantity ändern, wird wiederholt ausgeführt.

Nachdem wir unser eigenes Reaktivitätssystem geschrieben haben, erscheint Ihnen diese Abbildung aus der Vue-Dokumentation vertraut und verständlich.


Vue-Reaktivitätssystem

Sehen Sie diesen schönen lila Kreis, der sagt, die Getter und Setter enthalten? Jetzt sollte er dir vertraut sein. Jede Instanz der Komponente verfügt über eine Instanz der Beobachtermethode (blauer Kreis), die Abhängigkeiten von Gettern sammelt (rote Linie). Wenn der Setter später aufgerufen wird, benachrichtigt er die Beobachtermethode, was zum erneuten Rendern der Komponente führt. Hier ist das gleiche Schema mit Erklärungen, die es mit unserer Entwicklung verbinden.


Vue-Reaktivitätsdiagramm mit Erläuterungen

Wir glauben, dass dieses Schema, nachdem wir unser eigenes Reaktivitätssystem geschrieben haben, keine zusätzlichen Erklärungen benötigt.

Natürlich ist dies in Vue komplizierter, aber jetzt sollten Sie den Mechanismus verstehen, der Reaktivitätssystemen zugrunde liegt.

Zusammenfassung


Nachdem Sie dieses Material gelesen haben, haben Sie Folgendes gelernt:

  • So erstellen Sie eine Dep Klasse, die Funktionen mithilfe der depend Methode sammelt und sie bei Bedarf mithilfe der notify Methode erneut aufruft.
  • So erstellen Sie eine Überwachungsfunktion, mit der Sie den von uns ausgeführten Code steuern können (dies ist die target ), den Sie möglicherweise im Objekt der Dep Klasse speichern müssen.
  • Verwendung der Object.defineProperty() -Methode zum Erstellen von Get- und Setter-Methoden.

All dies, zusammengefasst in einem einzigen Beispiel, führte zur Schaffung eines Reaktionssystems in reinem JavaScript, indem Sie verstehen, welche Funktionsmerkmale solcher Systeme in modernen Webframeworks verwendet werden können.

Liebe Leser! Wenn Sie sich vor dem Lesen dieses Materials die Merkmale der Mechanismen von Reaktivitätssystemen schlecht vorgestellt haben, sagen Sie mir, haben Sie es jetzt geschafft, mit ihnen umzugehen?

Source: https://habr.com/ru/post/de418633/


All Articles