Reaktive UI's mit VanillaJS – Teil 2: Klassenbasierte Komponenten

Avatar of Brandon Smith
Brandon Smith am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

In Teil 1 habe ich verschiedene Techniken im funktionalen Stil durchgesprochen, um HTML basierend auf JavaScript-Daten sauber darzustellen. Wir haben unsere UI in Komponentenfunktionen zerlegt, die jeweils einen Teil des Markups als Funktion von Daten zurückgaben. Diese haben wir dann zu Ansichten zusammengesetzt, die durch einen einzigen Funktionsaufruf aus neuen Daten rekonstruiert werden konnten.

Das ist die Bonusrunde. In diesem Beitrag wird das Ziel sein, der vollwertigen, klassenbasierten React-Komponentensyntax so nahe wie möglich zu kommen, und zwar mit VanillaJS (d.h. nativem JavaScript ohne Bibliotheken/Frameworks). Ich möchte vorausschicken, dass einige der hier vorgestellten Techniken nicht übermäßig praktisch sind, aber ich denke, sie werden dennoch eine unterhaltsame und interessante Erkundung dessen bieten, wie weit JavaScript in den letzten Jahren gekommen ist und was genau React für uns leistet.

Artikelserie

  1. Reiner funktionaler Stil
  2. Klassenbasierte Komponenten (Sie sind hier!)

Von Funktionen zu Klassen

Lassen Sie uns das gleiche Beispiel wie im ersten Beitrag weiterverwenden: ein Blog. Unsere funktionale BlogPost-Komponente sah so aus

var blogPostData = {
  author: 'Brandon Smith',
  title: 'A CSS Trick',
  body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
};

function BlogPost(postData) {
  return `<div class="post">
            <h1>${postData.title}</h1>
            <h3>By ${postData.author}</h3>
            <p>${postData.body}</p>
          </div>`;
}

document.querySelector('body').innerHTML = BlogPost(blogPostData);

Bei klassenbasierten Komponenten benötigen wir weiterhin dieselbe Rendering-Funktion, binden sie jedoch als Methode einer Klasse ein. Instanzen der Klasse halten ihre eigenen BlogPost-Daten und wissen, wie sie sich selbst rendern.

var blogPostData = {
  author: 'Brandon Smith',
  title: 'A CSS Trick',
  body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
};

class BlogPost {

  constructor(props) {
    this.state = {
      author: props.author,
      title: props.title,
      body: props.body
    }
  }

  render() {
    return `<div class="post">
              <h1>${this.state.title}</h1>
              <h3>By ${this.state.author}</h3>
              <p>${this.state.body}</p>
            </div>`;
  }

}

var blogPostComponent = new BlogPost(blogPostData);

document.querySelector('body').innerHTML = blogPostComponent.render();

Zustand ändern

Der Vorteil eines klassenbasierten (objektorientierten) Programmierstils ist, dass er die Kapselung von Zustand ermöglicht. Stellen wir uns vor, unsere Blog-Seite erlaubt es Admin-Benutzern, ihre Blog-Posts direkt auf derselben Seite zu bearbeiten, auf der Leser sie sehen. Instanzen der BlogPost-Komponente könnten ihren eigenen Zustand verwalten, getrennt von der äußeren Seite und/oder anderen Instanzen von BlogPost. Wir können den Zustand über eine Methode ändern

class BlogPost {

  constructor(props) {
    this.state = {
      author: props.author,
      title: props.title,
      body: props.body
    }
  }

  render() {
    return `<div class="post">
              <h1>${this.state.title}</h1>
              <h3>By ${this.state.author}</h3>
              <p>${this.state.body}</p>
            </div>`;
  }

  setBody(newBody) {
    this.state.body = newBody;
  }

}

In einem realen Szenario müsste diese Zustandsänderung jedoch entweder durch eine Netzwerkanfrage oder ein DOM-Ereignis ausgelöst werden. Lassen Sie uns untersuchen, wie letzteres aussehen würde, da dies der häufigste Fall ist.

Ereignisse behandeln

Normalerweise ist das Abhören von DOM-Ereignissen unkompliziert – verwenden Sie einfach element.addEventListener() –, aber die Tatsache, dass unsere Komponenten nur zu Zeichenfolgen ausgewertet werden und nicht zu tatsächlichen DOM-Elementen, macht es schwieriger. Wir haben kein Element, an das wir binden können, und das einfache Einfügen eines Funktionsaufrufs in onchange reicht nicht aus, da es nicht an unsere Komponentinstanz gebunden wäre. Wir müssen irgendwie von globaler Ebene auf unsere Komponente verweisen, wo der Snippet ausgewertet wird. Hier ist meine Lösung

document.componentRegistry = { };
document.nextId = 0;

class Component {
  constructor() {
    this._id = ++document.nextId;
    document.componentRegistry[this._id] = this;
  }
}

class BlogPost extends Component {

  constructor(props) {
    super();

    this.state = {
      author: props.author,
      title: props.title,
      body: props.body
    }
  }

  render() {
    return `<div class="post">
              <h1>${this.state.title}</h1>
              <h3>By ${this.state.author}</h3>
              <textarea onchange="document.componentRegistry[${this._id}].setBody(this.value)">
                ${this.state.body}
              </textarea>
            </div>`;
  }

  setBody(newBody) {
    this.state.body = newBody;
  }

}

Okay, hier passiert einiges.

Referenzierung der Komponentinstanz

Zuerst mussten wir aus der HTML-Zeichenkette eine Referenz auf die aktuelle Instanz der Komponente erhalten. React kann dies einfacher tun, da JSX tatsächlich in eine Reihe von Funktionsaufrufen und nicht in eine HTML-Zeichenkette umgewandelt wird. Dies ermöglicht es dem Code, this direkt zu übergeben, und die Referenz auf das JavaScript-Objekt bleibt erhalten. Wir hingegen müssen eine Zeichenkette von JavaScript serialisieren, um sie in unsere Zeichenkette von HTML einzufügen. Daher muss die Referenz auf unsere Komponentinstanz irgendwie als Zeichenkette dargestellt werden. Um dies zu erreichen, weisen wir jeder Komponentinstanz beim Konstruieren eine eindeutige ID zu. Sie müssen dieses Verhalten nicht in einer übergeordneten Klasse unterbringen, aber es ist ein guter Anwendungsfall für Vererbung. Im Wesentlichen geschieht Folgendes: Wann immer eine BlogPost-Instanz konstruiert wird, erstellt sie eine neue ID, speichert sie als Eigenschaft auf sich selbst und registriert sich unter dieser ID in document.componentRegistry. Nun kann jeder JavaScript-Code überall unser Objekt abrufen, wenn er diese ID hat. Andere Komponenten, die wir möglicherweise schreiben, könnten ebenfalls von der Component-Klasse erben und automatisch eigene eindeutige IDs erhalten.

Aufruf der Methode

Wir können also die Komponentinstanz aus einer beliebigen JavaScript-Zeichenkette abrufen. Als Nächstes müssen wir die Methode darauf aufrufen, wenn unser Ereignis ausgelöst wird (onchange). Isolieren wir den folgenden Ausschnitt und gehen wir durch, was passiert

<textarea onchange="document.componentRegistry[${this._id}].setBody(this.value)">
  ${this.state.body}
</textarea>

Sie sind wahrscheinlich mit dem Hinzufügen von Event-Listenern vertraut, indem Sie Code in on_______ HTML-Attribute einfügen. Der darin enthaltene Code wird ausgewertet und ausgeführt, wenn das Ereignis ausgelöst wird.

document.componentRegistry[${this._id}] sucht im Komponenten-Registry und holt die Komponentinstanz anhand ihrer ID. Denken Sie daran, dass dies alles innerhalb einer Template-Zeichenkette geschieht, sodass ${this._id} zur ID der aktuellen Komponente ausgewertet wird. Der resultierende HTML wird so aussehen

<textarea onchange="document.componentRegistry[0].setBody(this.value)">
  Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</textarea>

Wir rufen die Methode auf diesem Objekt auf und übergeben this.value (wobei this das Element ist, auf dem das Ereignis stattfindet; in unserem Fall <textarea>) als newBody.

Aktualisierung als Reaktion auf Zustandsänderungen

Der Wert unserer JavaScript-Variable wird geändert, aber wir müssen tatsächlich ein erneutes Rendering durchführen, um seinen Wert auf der gesamten Seite widergespiegelt zu sehen. In unserem letzten Artikel haben wir das Rendering so durchgeführt

function update() {
  document.querySelector('body').innerHTML = BlogPost(blogPostData);
}

An dieser Stelle müssen wir einige Anpassungen für klassenbasierte Komponenten vornehmen. Wir wollen unsere Komponentinstanzen nicht jedes Mal wegwerfen und neu aufbauen, wenn wir rendern; wir wollen nur die HTML-Zeichenkette neu erstellen. Der interne Zustand muss erhalten bleiben. Unsere Objekte werden also separat existieren, und wir rufen einfach erneut render() auf

var blogPost = new BlogPost(blogPostData);

function update() {
  document.querySelector('body').innerHTML = blogPost.render();
}

Wir müssen dann update() aufrufen, wann immer wir den Zustand ändern. Das ist eine weitere Sache, die React transparent für uns erledigt; seine setState()-Funktion ändert den Zustand und löst auch ein erneutes Rendering für diese Komponente aus. Das müssen wir manuell tun

// ...
setBody(newBody) {
  this.state.body = newBody;
  update();
}
// ...

Beachten Sie, dass es selbst bei einer komplexen verschachtelten Struktur von Komponenten immer nur eine einzige update()-Funktion gibt und diese immer auf die Root-Komponente angewendet wird.

Kindkomponenten

React (sowie praktisch alle anderen JavaScript-Frameworks) unterscheidet zwischen Elementen und Komponenten, die eine Komponente ausmachen, und denen, die ihre Kinder sind. Kinder können von außen übergeben werden, was uns erlaubt, benutzerdefinierte Komponenten zu schreiben, die Container für beliebige andere Inhalte sind. Das können wir auch tun.

class BlogPost extends Component {

  constructor(props, children) {
    super();

    this.children = children;
    this.state = {
      author: props.author,
      title: props.title,
      body: props.body
    }
  }

  render() {
    return `<div class="post">
              <h1>${this.state.title}</h1>
              <h3>By ${this.state.author}</h3>
              <textarea onchange="document.componentRegistry[${this._id}].setBody(this.value)">
                ${this.state.body}
              </textarea>
              <div>
                ${this.children.map((child) => child.render()).join('')}
              </div>
            </div>`;
  }

  setBody(newBody) {
    this.state.body = newBody;
    update();
  }

}

Dies ermöglicht es uns, Nutzungs-Code wie den folgenden zu schreiben

var adComponents = ...;
var blogPost = new BlogPost(blogPostData, adComponents);

was die Komponenten an der vorgesehenen Stelle im Markup einfügen wird.

Abschließende Gedanken

React scheint einfach, aber es tut viele subtile Dinge, um uns das Leben viel einfacher zu machen. Das Offensichtlichste ist die Leistung; nur die Komponenten neu zu rendern, deren Zustand sich aktualisiert, und die durchgeführten DOM-Operationen drastisch zu minimieren. Aber einige der weniger offensichtlichen Dinge sind auch wichtig.

Eines davon ist, dass React durch granulare DOM-Änderungen statt durch vollständiges Neuerstellen des DOM einige natürliche DOM-Zustände beibehält, die bei unserer Technik verloren gehen. Dinge wie CSS-Übergänge, vom Benutzer vergrößerte Textbereiche, Fokus und Cursorposition in einem Eingabefeld gehen verloren, wenn wir das DOM löschen und neu erstellen. Für unseren Anwendungsfall ist das machbar. Aber in vielen Situationen ist es das vielleicht nicht. Natürlich könnten wir DOM-Änderungen selbst vornehmen, aber dann sind wir wieder am Anfang und verlieren unsere deklarative, funktionale Syntax.

React bietet uns die Vorteile der DOM-Manipulation und ermöglicht es uns gleichzeitig, unseren Code in einem wartungsfreundlicheren, deklarativeren Stil zu schreiben. Wir haben gezeigt, dass Vanilla JavaScript beides kann, aber nicht beides gleichzeitig haben kann.

Artikelserie

  1. Reiner funktionaler Stil
  2. Klassenbasierte Komponenten (Sie sind hier!)