Kapitel 4. Funktionales Excel

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

Erinnerst du dich an Funktionskomponenten? Irgendwann in Kapitel 2, als der Zustand ins Spiel kam, wurden die Funktionskomponenten aus der Diskussion gestrichen. Es ist an der Zeit, sie wieder ins Spiel zu bringen.

Eine kurze Auffrischung: Funktion versus Klassenkomponenten

In ihrer einfachsten Form braucht eine Klassenkomponente nur eine render() Methode. Hier baust du die Benutzeroberfläche auf, optional mit this.props und this.state:

class Widget extends React.Component {
  render() {
    let ui;
    // fun with this.props and this.state
    return <div>{ui}</div>;
  }
}

In einer Funktionskomponente ist die gesamte Komponente die Funktion und die Benutzeroberfläche ist das, was die Funktion zurückgibt. Die Requisiten werden an die Funktion übergeben, wenn die Komponente erstellt wird:

function Widget(props) {
    let ui;
    // fun with props but where's the state?
    return <div>{ui}</div>;
}

Die Nützlichkeit von Funktionskomponenten endete mit React v16.8: Du kannst sie nur für Komponenten verwenden, die keinen Zustand verwalten(zustandslose Komponenten). Mit der Einführung von Hooks in Version 16.8 ist es nun möglich, Funktionskomponenten überall zu verwenden. Im weiteren Verlauf dieses Kapitels wirst du sehen, wie die Komponente Excel aus Kapitel 3 als Funktionskomponente implementiert werden kann.

Rendering der Daten

Der erste Schritt besteht darin, die an die Komponente übergebenen Daten zu rendern(Abbildung 4-1). Wie die Komponente verwendet wird, ändert sich nicht. Mit anderen Worten: Ein Entwickler, der deine Komponente verwendet, muss nicht wissen, ob es sich um eine Klassen- oder eine Funktionskomponente handelt. Die Requisiten von initialData und headers sehen gleich aus. Sogar die propTypes Definitionen sind identisch.

function Excel(props) {
  // implement me...
}

Excel.propTypes = {
  headers: PropTypes.arrayOf(PropTypes.string),
  initialData: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
};

const headers = ['Book', 'Author', 'Language', 'Published', 'Sales'];

const data = [
  [
    'A Tale of Two Cities', 'Charles Dickens', // ...
  ],
  // ...
];

ReactDOM.render(
  <Excel headers={headers} initialData={data} />,
  document.getElementById('app'),
);

Die Implementierung des Körpers der Funktionskomponente besteht im Wesentlichen aus dem Kopieren des Körpers der render() Methode der Klassenkomponente:

function Excel({headers, initialData}) {
  return (
    <table>
      <thead>
        <tr>
          {headers.map((title, idx) => (
            <th key={idx}>{title}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {initialData.map((row, idx) => (
          <tr key={idx}>
            {row.map((cell, idx) => (
              <td key={idx}>{cell}</td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Im obigen Code kannst du sehen, dass du anstelle von function Excel(props){} die Destrukturierungssyntax function Excel({headers, initialData}){} verwenden kannst, um dir später die Eingabe von props.headers und props.initialData zu ersparen.

rur2 0401
Abbildung 4-1. Rendering der Tabelle in einer Funktionskomponente (siehe 04.01.fn.table.html)

Der Staatshaken

Um den Zustand in deinen Funktionskomponenten zu erhalten, brauchst du Hooks. Was ist ein Hook? Eine Funktion mit dem vorangestellten Wort use*, mit der du verschiedene React-Funktionen nutzen kannst, z. B. Werkzeuge zur Verwaltung von Zuständen und Lebenszyklen von Komponenten. Du kannst auch deine eigenen Hooks erstellen. Am Ende dieses Kapitels lernst du, wie du verschiedene eingebaute Hooks nutzen und deine eigenen schreiben kannst.

Beginnen wir mit dem State Hook. Es handelt sich um eine Funktion namens useState(), die als Eigenschaft des React Objekts (React.useState()) verfügbar ist. Sie nimmt einen Wert an, den Anfangswert einer Zustandsvariablen (ein Teil der Daten, die du verwalten willst), und gibt ein Array mit zwei Elementen (ein Tupel) zurück. Das erste Element ist die Zustandsvariable und das zweite ist eine Funktion, mit der du diese Variable ändern kannst. Schauen wir uns ein Beispiel an.

In einer Klassenkomponente definierst du in der constructor() den Anfangswert wie folgt:

this.state = {
  data: initialData;
};

Wenn du später den Status von data ändern möchtest, kannst du stattdessen Folgendes tun:

this.setState({
  data: newData,
});

In einer Funktion Komponente definierst du den Ausgangszustand und erhältst eine Aktualisierungsfunktion:

const [data, setData] = React.useState(initialData);
Hinweis

Beachte die Destrukturierungssyntax des Arrays , bei der du die beiden Elemente des Arrays, das von useState() zurückgegeben wird, zwei Variablen zuweist: data und setData. Das ist ein kürzerer und saubererer Weg, um die beiden Rückgabewerte zu erhalten, als wenn du z. B:

const stateArray = React.useState(initialData);
const data = stateArray[0];
const setData = stateArray[1];

Zum Rendern kannst du jetzt die Variable data verwenden. Wenn du diese Variable aktualisieren willst, benutze:

setData(newData);

Wenn du die Komponente so umschreibst, dass sie den State Hook verwendet, kann das folgendermaßen aussehen:

function Excel({headers, initialData}) {
  const [data, setData] = React.useState(initialData);

  return (
    <table>
      <thead>
        <tr>
          {headers.map((title, idx) => (
            <th key={idx}>{title}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {data.map((row, idx) => (
          <tr key={idx}>
            {row.map((cell, idx) => (
              <td key={idx}>{cell}</td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Auch wenn dieses Beispiel (siehe 04.02.fn.table-state.html) nicht setData() verwendet, kannst du sehen, wie es den data Status verwendet. Gehen wir nun zum Sortieren der Tabelle über, wo du die Möglichkeit brauchst, den Status zu ändern.

Sortieren der Tabelle

In einer Komponente der Klasse werden alle verschiedenen Zustandsdaten in das Objekt this.state gespeichert, ein Sammelsurium von oft zusammenhangslosen Informationen. Mit dem State Hook kannst du das Gleiche tun, aber du kannst auch entscheiden, ob du Teile des Zustands in verschiedenen Variablen speichern willst. Wenn es darum geht, eine Tabelle zu sortieren, ist die data, die in der Tabelle enthalten ist, eine Information, während die sortierungsspezifischen Zusatzinformationen eine andere Information sind. Mit anderen Worten: Du kannst den State Hook so oft verwenden, wie du willst.

function Excel({headers, initialData}) {
  const [data, setData] = React.useState(initialData);
  const [sorting, setSorting] = React.useState({
    column: null,
    descending: false,
  });

  // ....
}

Das data ist das, was du in der Tabelle anzeigst; das sorting Objekt ist eine separate Angelegenheit. Es geht darum, wie du sortierst (aufsteigend oder absteigend) und nach welcher Spalte (Titel, Autor, etc.).

Die Funktion, die die Sortierung vornimmt, ist jetzt inline innerhalb der Funktion Excel:

function Excel({headers, initialData}) {

  // ..

  function sort(e) {
    // implement me
  }

  return (
    <table>
      {/* ... */}
    </table>
  );
}

Die Funktion sort() findet heraus, nach welcher Spalte sortiert werden soll (anhand ihres Index) und ob die Sortierung absteigend ist:

const column = e.target.cellIndex;
const descending = sorting.column === column && !sorting.descending;

Dann wird das Array data geklont, denn es ist immer noch eine schlechte Idee, den Zustand direkt zu ändern:

const dataCopy = clone(data);
Hinweis

Eine Erinnerung , dass die Funktion clone() immer noch die schnelle und schmutzige JSON-Encodierung/Decodierung für das Deep Copying ist:

function clone(o) {
  return JSON.parse(JSON.stringify(o));
}

Die eigentliche Sortierung ist dieselbe wie zuvor:

dataCopy.sort((a, b) => {
  if (a[column] === b[column]) {
    return 0;
  }
  return descending
    ? a[column] < b[column]
      ? 1
      : -1
    : a[column] > b[column]
      ? 1
      : -1;
});

Und schließlich muss die Funktion sort() die beiden Zustandsdaten mit den neuen Werten aktualisieren:

setData(dataCopy);
setSorting({column, descending});

Und das war's dann auch schon mit dem Sortieren. Jetzt muss nur noch die Benutzeroberfläche (der Rückgabewert der Funktion Excel() ) aktualisiert werden, um zu zeigen, welche Spalte für die Sortierung verwendet wird, und um Klicks auf eine der Überschriften zu verarbeiten:

<thead onClick={sort}>
  <tr>
    {headers.map((title, idx) => {
      if (sorting.column === idx) {
        title += sorting.descending ? ' \u2191' : ' \u2193';
      }
      return <th key={idx}>{title}</th>;
    })}
  </tr>
</thead>

Du kannst das Ergebnis mit dem Sortierpfeil in Abbildung 4-2 sehen.

Vielleicht hast du eine weitere nette Sache bei der Verwendung von State Hooks bemerkt: Du musst keine Callback-Funktionen binden, wie du es im Konstruktor einer Klassenkomponente tust. Nichts von dieser this.sort = this.sort.bind(this) Angelegenheit. Kein this, kein constructor(). Eine Funktion ist alles, was du brauchst, um eine Komponente zu definieren.

rur2 0402
Abbildung 4-2. Sortieren der Daten (siehe 04.03.fn.table-sort.html)

Daten bearbeiten

Wie du dich aus Kapitel 3 erinnerst, besteht die Bearbeitungsfunktion aus den folgenden Schritten:

  1. Wenn du auf eine Tabellenzelle doppelklickst, verwandelt sie sich in ein Texteingabeformular.

  2. Du tippst in das Texteingabeformular.

  3. Wenn du fertig bist, drückst du die Eingabetaste, um das Formular abzuschicken.

Um den Überblick über diesen Prozess zu behalten, fügen wir ein edit Statusobjekt hinzu. Es ist null, wenn keine Bearbeitung stattfindet; ansonsten speichert es die Zeilen- und Spaltenindizes der bearbeiteten Zelle.

const [edit, setEdit] = useState(null);

In der Benutzeroberfläche musst du Doppelklicks behandeln (onDoubleClick={showEditor}) und, wenn der Benutzer etwas bearbeitet, ein Formular anzeigen. Ansonsten zeigst du nur die Daten an. Wenn der Benutzer die Eingabetaste drückt, fängst du das Submit-Ereignis ab (onSubmit={save}).

<tbody onDoubleClick={showEditor}>
  {data.map((row, rowidx) => (
    <tr key={rowidx} data-row={rowidx}>
      {row.map((cell, columnidx) => {
        if (
          edit &&
          edit.row === rowidx &&
          edit.column === columnidx
        ) {
          cell = (
            <form onSubmit={save}>
              <input type="text" defaultValue={cell} />
            </form>
          );
        }
        return <td key={columnidx}>{cell}</td>;
      })}
    </tr>
  ))}
</tbody>

Es gibt zwei Kurzfunktionen, die noch implementiert werden müssen: showEditor() und save().

Die showEditor() wird durch einen Doppelklick auf eine Zelle im Tabellenkörper aufgerufen. Dort aktualisierst du den edit Status (über setEdit()) mit Zeilen- und Spaltenindizes, damit das Rendering weiß, welche Zellen durch ein Formular ersetzt werden sollen.

function showEditor(e) {
  setEdit({
    row: parseInt(e.target.parentNode.dataset.row, 10),
    column: e.target.cellIndex,
  });
}

Die Funktion save() fängt das Submit-Ereignis des Formulars ab, verhindert das Absenden und aktualisiert den data Status mit dem neuen Wert in der bearbeiteten Zelle. Außerdem ruft siesetEdit() auf und übergibt null als neuen Bearbeitungsstatus, was bedeutet, dass die Bearbeitung abgeschlossen ist.

function save(e) {
  e.preventDefault();
  const input = e.target.firstChild;
  const dataCopy = clone(data);
  dataCopy[edit.row][edit.column] = input.value;
  setEdit(null);
  setData(dataCopy);
}

Und damit ist die Bearbeitungsfunktion fertig. Den vollständigen Code findest du in der Datei 04.04.fn.table-edit.html im Repo des Buches.

Suche

Das Durchsuchen/Filtern der Daten stellt keine neuen Herausforderungen dar, wenn es um React und Hooks geht. Du kannst versuchen, es selbst zu implementieren und die Implementierung in 04.05.fn.table-search.html im Repo des Buches referenzieren.

Du brauchst zwei neue Teile des Staates:

  • Der boolesche Wert search, der angibt, ob der Benutzer filtert oder sich die Daten nur ansieht

  • Die Kopie von data als preSearchData, denn nun wird data eine gefilterte Teilmenge aller Daten

const [search, setSearch] = useState(false);
const [preSearchData, setPreSearchData] = useState(null);

Du musst darauf achten, dass preSearchData immer auf dem neuesten Stand ist, da data (die gefilterte Teilmenge) aktualisiert werden kann, wenn der Benutzer bearbeitet und gleichzeitig filtert. Schau zur Auffrischung in Kapitel 3 nach.

Weiter geht es mit , wo du die Wiederholungsfunktion implementierst und dich mit zwei neuen Konzepten vertraut machen kannst:

  • Lebenszyklus-Haken verwenden

  • Eigene Aufhänger schreiben

Lebenszyklen in einer Welt voller Haken

Die Wiederholungsfunktion in Kapitel 3 verwendet zwei Lebenszyklusmethoden der Klasse Excel: componentDidMount() und componentWillUnmount().

Probleme mit Lebenszyklusmethoden

Wenn du dir das Beispiel 03.14.table-fetch.html auf noch einmal ansiehst, wirst du feststellen, dass jede dieser Aufgaben zwei Aufgaben hat, die nichts miteinander zu tun haben:

componentDidMount() {
  document.addEventListener('keydown', this.keydownHandler);
  fetch('https://www...')
    .then(/*...*/)
    .then((initialData) => {
      /*...*/
      this.setState({data});
    });
}

componentWillUnmount() {
  document.removeEventListener('keydown', this.keydownHandler);
  clearInterval(this.replayID);
}

In componentDidMount() richtest du einen keydown Listener ein, um die Wiedergabe zu starten und Daten von einem Server abzurufen. In componentWillUnmount() entfernst du den keydown Listener und bereinigst außerdem eine setInterval() ID. Dies veranschaulicht zwei Probleme im Zusammenhang mit der Verwendung von Lifecycle-Methoden in Klassenkomponenten (die bei der Verwendung von Hooks gelöst werden):

Unzusammenhängende Aufgaben werden gemeinsam ausgeführt

Zum Beispiel das Abrufen von Daten und das Einrichten von Event-Listenern an einem Ort. Das führt dazu, dass die Lebenszyklusmethoden immer länger werden, während sie die nicht miteinander verbundenen Aufgaben ausführen. Bei einfachen Komponenten ist das in Ordnung, aber bei größeren Komponenten musst du auf Codekommentare zurückgreifen oder Teile des Codes in verschiedene andere Funktionen verschieben, um die nicht zusammenhängenden Aufgaben aufzuteilen und den Code besser lesbar zu machen.

Zusammenhängende Aufgaben werden aufgeteilt

Betrachte zum Beispiel das Hinzufügen und Entfernen desselben Ereignis-Listeners. Je größer die Lebenszyklusmethoden werden, desto schwieriger ist es, die einzelnen Teile desselben Anliegens auf einen Blick zu erfassen, weil sie beim späteren Lesen einfach nicht mehr in denselben Code passen.

useEffect()

Der eingebaute Haken, der die beiden oben genannten Lebenszyklusmethoden ersetzt, ist React.use​Ef⁠fect().

Hinweis

Das Wort "Effekt" steht für "Nebeneffekt" und bezeichnet eine Art von Arbeit, die nichts mit der Hauptaufgabe zu tun hat, aber ungefähr zur gleichen Zeit passiert. Die Hauptaufgabe jeder React-Komponente besteht darin, etwas auf der Grundlage von Zuständen und Requisiten zu rendern. Aber das Rendering kann gleichzeitig (in derselben Funktion) neben einigen Nebenaufträgen (wie dem Abrufen von Daten von einem Server oder dem Einrichten von Ereignis-Listenern) notwendig sein.

In der Komponente Excel zum Beispiel ist das Einrichten eines keydown Handlers ein Nebeneffekt der Hauptaufgabe, die Daten in einer Tabelle darzustellen.

Der Haken useEffect() nimmt zwei Argumente entgegen:

  • Eine Callback-Funktion, die von React zum richtigen Zeitpunkt aufgerufen wird

  • Ein optionales Array von Abhängigkeiten

Die Liste der Abhängigkeiten enthält Variablen, die geprüft werden, bevor der Callback aufgerufen wird, und die bestimmen, ob der Callback überhaupt aufgerufen werden soll.

  • Wenn sich die Werte der abhängigen Variablen nicht geändert haben, ist es nicht nötig, den Callback aufzurufen.

  • Wenn die Liste der Abhängigkeiten ein leeres Array ist, wird der Callback nur einmal aufgerufen, ähnlich wie bei componentDidMount().

  • Wenn die Abhängigkeiten weggelassen werden, wird der Callback bei jedem Rerender aufgerufen

useEffect(() => {
  // logs only if `data` or `headers` have changed
  console.log(Date.now());
}, [data, headers]);

useEffect(() => {
  // logs once, after initial render, like `componentDidMount()`
  console.log(Date.now());
}, []);

useEffect(() => {
  // called on every re-render
  console.log(Date.now());
}, /* no dependencies here */);

Aufräumen von Nebeneffekten

Jetzt weißt du, wie du mit Hooks das erreichen kannst, was componentDidMount() an Klassenkomponenten zu bieten hat. Aber wie sieht es mit einer Entsprechung zu componentWill​Un⁠mount() aus? Für diese Aufgabe verwendest du den Rückgabewert der Callback-Funktion, die du an useEffect() übergibst:

useEffect(() => {
  // logs once, after initial render, like `componentDidMount()`
  console.log(Date.now());
  return () => {
    // log when the component will be removed form the DOM
    // like `componentDidMount()`
    console.log(Date.now());
  };
}, []);

Schauen wir uns ein ausführlicheres Beispiel an(04.06.useEffect.html im Repo):

function Example() {
  useEffect(() => {
    console.log('Rendering <Example/>', Date.now());
    return () => {
      // log when the component will be removed form the DOM
      // like `componentDidMount()`
      console.log('Removing <Example/>', Date.now());
    };
  }, []);
  return <p>I am an example child component.</p>;
}

function ExampleParent() {
  const [visible, setVisible] = useState(false);
  return (
    <div>
      <button onClick={() => setVisible(!visible)}>
        Hello there, press me {visible ? 'again' : ''}
      </button>
      {visible ? <Example /> : null}
    </div>
  );
}

Wenn du einmal auf die Schaltfläche klickst, wird eine untergeordnete Komponente angezeigt, und wenn du erneut darauf klickst, wird sie entfernt. Wie du in Abbildung 4-3 sehen kannst, wird der Rückgabewert von useEffect() (eine Funktion) aufgerufen, wenn die Komponente aus dem DOM entfernt wird.

rur2 0403
Abbildung 4-3. verwenden useEffect

Beachte, dass die Funktion cleanup (auch bekannt als teardown) aufgerufen wurde, wenn die Komponente aus dem DOM entfernt wird, weil das Abhängigkeitsarray leer ist. Wäre ein Wert im Abhängigkeitsfeld vorhanden, würde die Abrissfunktion immer dann aufgerufen werden, wenn sich der Wert der Abhängigkeit ändert.

Störungsfreie Lebenszyklen

Wenn du dir noch einmal den Anwendungsfall vor Augen führst, bei dem es um das Einrichten und Löschen von Ereignis-Listenern geht, kann das so umgesetzt werden:

useEffect(() => {
  function keydownHandler() {
    // do things
  }
  document.addEventListener('keydown', keydownHandler);
  return () => {
    document.removeEventListener('keydown', keydownHandler);
  };
}, []);

Das obige Muster löst das zweite Problem, das mit klassenbasierten Lebenszyklusmethoden hat, nämlich das Problem, zusammenhängende Aufgaben über die ganze Komponente zu verteilen. Hier kannst du sehen, wie die Verwendung von Hooks es dir ermöglicht, die Handler-Funktion, ihre Einrichtung und ihr Entfernen an einem Ort zu haben.

Das erste Problem (nicht zusammenhängende Aufgaben am selben Ort) lässt sich durch mehrere useEffect Aufrufe lösen, die jeweils einer bestimmten Aufgabe gewidmet sind. Ähnlich wie man statt eines Grab-Bag-Objekts verschiedene Zustände haben kann, kann man auch getrennte useEffect Aufrufe haben, die sich jeweils um ein bestimmtes Anliegen kümmern, anstatt einer einzigen Klassenmethode, die sich um alles kümmern muss:

function Example() {
  const [data, setData] = useState(null);

  useEffect(() => {
    // fetch() and then call setData()
  });

  useEffect(() => {
    // event handlers
  });

  return <div>{data}</div>;
}

useLayoutEffect()

Zum Abschluss der Diskussion über useEffect() wollen wir einen weiteren eingebauten Haken namens useLayoutEffect() betrachten.

Hinweis

Es gibt nur ein paar eingebaute Hooks, also mach dir keine Sorgen, dass du eine lange Liste neuer APIs auswendig lernen musst.

useLayoutEffect() funktioniert wie useEffect(), mit dem einzigen Unterschied, dass aufgerufen wird, bevor React alle DOM-Knoten eines Renderings gezeichnet hat. Im Allgemeinen solltest du useEffect() verwenden, es sei denn, du musst etwas auf der Seite messen (z. B. die Abmessungen einer gerenderten Komponente oder die Scroll-Position nach einer Aktualisierung) und dann auf der Grundlage dieser Informationen neu rendern. Wenn nichts dergleichen erforderlich ist, ist useEffect() besser, da es asynchron ist und dem Leser deines Codes außerdem anzeigt, dass DOM-Veränderungen für deine Komponente nicht relevant sind.

Da useLayoutEffect() früher aufgerufen wird, kannst du neu berechnen und rendern und der Nutzer sieht nur das letzte Rendering. Andernfalls sieht er zuerst das erste Rendering und dann das zweite Rendering. Je nachdem, wie kompliziert das Layout ist, kann der Nutzer ein Flackern zwischen den beiden Renderings wahrnehmen.

Das nächste Beispiel(04.07.useLayoutEffect.html im Repo) rendert eine lange Tabelle mit zufälligen Zellenbreiten (nur um es dem Browser schwerer zu machen). Dann wird die Breite der Tabelle in einem Effekt-Hook festgelegt.

function Example({layout}) {
  if (layout === null) {
    return null;
  }

  if (layout) {
    useLayoutEffect(() => {
      const table = document.getElementsByTagName('table')[0];
      console.log(table.offsetWidth);
      table.width = '250px';
    }, []);
  } else {
    useEffect(() => {
      const table = document.getElementsByTagName('table')[0];
      console.log(table.offsetWidth);
      table.width = '250px';
    }, []);
  }

  return (
    <table>
      <thead>
        <tr>
          <th>Random</th>
        </tr>
      </thead>
      <tbody>
        {Array.from(Array(10000)).map((_, idx) => (
          <tr key={idx}>
            <td width={Math.random() * 800}>{Math.random()}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

function ExampleParent() {
  const [layout, setLayout] = useState(null);
  return (
    <div>
      <button onClick={() => setLayout(false)}>useEffect</button>{' '}
      <button onClick={() => setLayout(true)}>useLayoutEffect</button>{' '}
      <button onClick={() => setLayout(null)}>clear</button>
      <Example layout={layout} />
    </div>
  );
}

Je nachdem, ob du den Pfad useEffect() oder useLayoutEffect() auslöst, kann es sein, dass du ein Flackern siehst, wenn die Tabelle von ihrem zufälligen Wert (ca. 600 px) auf die fest eingestellten 250 px verkleinert wird (siehe Abbildung 4-4).

rur2 0404
Abbildung 4-4. Flackernde Rerender

Beachte, dass du in beiden Fällen die Geometrie der Tabelle abrufen kannst (z. B. table​.off⁠setWidth). Wenn du dies also nur zu Informationszwecken brauchst und nicht neu rendern willst, bist du mit der asynchronen useEffect() besser dran. useLayout​Ef⁠fect() sollte dafür reserviert sein, Flackern in Fällen zu vermeiden, in denen du auf der Grundlage einer Messung handeln (neu rendern) musst, z. B. bei der Positionierung einer ausgefallenen Tooltip-Komponente auf der Grundlage der Größe des Elements, auf das sie zeigt.

Ein maßgeschneiderter Haken

Gehen wir zurück zu Excel und sehen wir uns an, wie wir die Wiederholungsfunktion implementieren können. Im Fall von Klassenkomponenten war es notwendig, eine logSetState() zu erstellen und dann alle this.setState() Aufrufe durch this.logSetState() zu ersetzen. Bei Funktionskomponenten kannst du alle Aufrufe des useState() Hooks durch useLoggedState() ersetzen. Das ist etwas bequemer, da es nur wenige Aufrufe gibt (für jedes unabhängige Bit des Zustands) und sie sich alle am Anfang der Funktion befinden.

// before
function Excel({headers, initialData}) {
  const [data, setData] = useState(initialData);
  const [edit, setEdit] = useState(null);
  // ... etc
}

// after
function Excel({headers, initialData}) {
  const [data, setData] = useLoggedState(initialData, true);
  const [edit, setEdit] = useLoggedState(null);
  // ... etc
}

Es gibt keinen eingebauten useLoggedState() Haken, aber das ist OK. Du kannst deine eigenen Hooks erstellen. Wie die eingebauten Hooks ist ein Custom Hook einfach eine Funktion, die mit use*() beginnt. Hier ist ein Beispiel:

function useLoggedState(initialValue, isData) {
  // ...
}

Die Signatur des Hooks kann alles sein, was du willst. In diesem Fall, , gibt es ein zusätzliches isData Argument. Es dient dazu, den Datenstatus vom Nicht-Datenstatus zu unterscheiden. Im Beispiel der Klassenkomponente aus Kapitel 3 ist der gesamte Zustand ein einziges Objekt, aber hier sind mehrere Teile des Zustands vorhanden. Bei der Wiedergabe geht es in erster Linie darum, die Datenänderungen zu zeigen und dann zu zeigen, dass alle unterstützenden Informationen (Sortieren, Absteigen usw.) zweitrangig sind. Da die Wiedergabe jede Sekunde aktualisiert wird, macht es keinen Spaß, sich die Änderungen der unterstützenden Daten einzeln anzusehen; die Wiedergabe wäre zu langsam. Wir haben also ein Hauptprotokoll (dataLog array) und ein Zusatzprotokoll (auxLog array). Außerdem ist es sinnvoll, ein Flag einzubauen, das angibt, ob sich der Zustand aufgrund einer Benutzerinteraktion oder (automatisch) während der Wiedergabe ändert:

let dataLog = [];
let auxLog = [];
let isReplaying = false;

Der benutzerdefinierte Hook soll die regulären Statusaktualisierungen nicht beeinträchtigen und delegiert diese Aufgabe daher an die ursprüngliche useState. Das Ziel ist es, den Status zusammen mit einem Verweis auf die Funktion zu protokollieren, die weiß, wie dieser Status während der Wiedergabe aktualisiert werden kann. Die Funktion sieht in etwa so aus:

function useLoggedState(initialValue, isData) {
  const [state, setState] = useState(initialValue);

  // fun here...

  return [state, setState];
}

Der obige Code verwendet den Standard useState. Aber jetzt hast du die Verweise auf einen Teil des Zustands und die Möglichkeit, ihn zu aktualisieren. Das musst du protokollieren. Hier können wir von dem useEffect() Hook profitieren:

function useLoggedState(initialValue, isData) {
  const [state, setState] = useState(initialValue);

  useEffect(() => {
    // todo
  }, [state]);

  return [state, setState];
}

Diese Methode stellt sicher, dass die Protokollierung nur erfolgt, wenn sich der Wert von state ändert. Die Funktion useLoggedState() kann während verschiedener Rerender mehrmals aufgerufen werden, aber du kannst diese Aufrufe ignorieren, es sei denn, sie beinhalten eine Änderung eines interessanten Zustands.

In dem Rückruf von useEffect() wirst du:

  • Mache nichts, wenn der/die Benutzer/in die Wiedergabe wiederholt.

  • Protokolliere jede Änderung des Datenzustands auf dataLog.

  • Protokolliere jede Änderung an den unterstützenden Daten auf auxLog, indiziert durch die zugehörige Datenänderung.

useEffect(() => {
  if (isReplaying) {
    return;
  }
  if (isData) {
    dataLog.push([clone(state), setState]);
  } else {
    const idx = dataLog.length - 1;
    if (!auxLog[idx]) {
      auxLog[idx] = [];
    }
    auxLog[idx].push([state, setState]);
  }
}, [state]);

Warum gibt es benutzerdefinierte Hooks? Sie helfen dir dabei, ein Stück Logik, das in einer Komponente verwendet und oft von mehreren Komponenten genutzt wird, zu isolieren und ordentlich zu verpacken. Die obige benutzerdefinierte useLoggedState() kann in jede Komponente eingefügt werden, die von der Protokollierung ihres Zustands profitieren kann. Außerdem können benutzerdefinierte Hooks andere Hooks aufrufen, was normale Funktionen (ohne Hook und ohne Komponente) nicht können.

Abschluss der Wiederholung

Jetzt, wo du einen benutzerdefinierten Hook hast, der die Änderungen an verschiedenen Zustandsgrößen protokolliert, ist es an der Zeit, mit der Wiedergabefunktion zu verbinden.

Die Funktion replay() ist kein aufregender Aspekt der React-Diskussion, aber sie richtet eine Intervall-ID ein. Du brauchst diese ID, um das Intervall aufzuräumen, falls Excel während der Wiedergabe aus dem DOM entfernt wird. Bei der Wiedergabe werden die Datenänderungen jede Sekunde wiedergegeben, während die Hilfsdaten zusammen gespült werden:

function replay() {
  isReplaying = true;
  let idx = 0;
  replayID = setInterval(() => {
    const [data, fn] = dataLog[idx];
    fn(data);
    auxLog[idx] &&
      auxLog[idx].forEach((log) => {
        const [data, fn] = log;
        fn(data);
      });
    idx++;
    if (idx > dataLog.length - 1) {
      isReplaying = false;
      clearInterval(replayID);
      return;
    }
  }, 1000);
}

Der letzte Teil der Arbeit besteht darin, einen Effekt-Hook einzurichten. Nachdem Excel gerendert wurde, ist der Hook dafür verantwortlich, Listener einzurichten, die eine bestimmte Tastenkombination überwachen, um die Wiedergabeshow zu starten. ist auch der Ort, an dem aufgeräumt wird, wenn die Komponente zerstört wird.

useEffect(() => {
  function keydownHandler(e) {
    if (e.altKey && e.shiftKey && e.keyCode === 82) {
      // ALT+SHIFT+R(eplay)
      replay();
    }
  }
  document.addEventListener('keydown', keydownHandler);
  return () => {
    document.removeEventListener('keydown', keydownHandler);
    clearInterval(replayID);
    dataLog = [];
    auxLog = [];
  };
}, []);

Um den gesamten Code zu sehen, schau dir 04.08.fn.table-replay.html im Repo des Buches an.

useReducer

Schließen wir das Kapitel mit einem weiteren eingebauten Hook namens useReducer() ab. Die Verwendung eines Reduzierers ist eine Alternative zu useState(). Anstatt verschiedene Teile der Komponente aufzurufen, die ihren Zustand ändern, können alle Änderungen an einer einzigen Stelle behandelt werden.

Ein Reducer ist einfach eine JavaScript-Funktion, die zwei Eingaben - den alten Zustand und eine Aktion - entgegennimmt und den neuen Zustand zurückgibt. Stell dir die Aktion als etwas vor, das in der App passiert ist, z. B. ein Klick, ein Datenabruf oder eine Zeitüberschreitung. Alle drei Variablen (neuer Zustand, alter Zustand, Aktion) können von beliebigem Typ sein, am häufigsten sind es jedoch Objekte.

Reduktionsfunktionen

Eine Reduktionsfunktion sieht in ihrer einfachsten Form wie folgt aus:

function myReducer(oldState, action) {
  const newState = {};
  // do something with `oldState` and `action`
  return newState;
}

Stell dir vor, dass die Reduktionsfunktion dafür verantwortlich ist, der Realität einen Sinn zu geben, wenn etwas in der Welt passiert. Die Welt ist ein mess, dann passiert ein event. Die Funktion, die die Welt makeSense() soll, bringt das Chaos mit dem neuen Ereignis in Einklang und reduziert die ganze Komplexität auf einen schönen Zustand oder order:

function makeSense(mess, event) {
  const order = {};
  // do something with mess and event
  return order;
}

Eine weitere Analogie stammt aus der Welt des Kochens. Manche Soßen und Suppen werden auch als Reduktionen bezeichnet, die durch den Prozess der Reduktion (Eindicken, Intensivierung des Geschmacks) entstehen. Der Ausgangszustand ist ein Topf mit Wasser, dann verändern verschiedene Aktionen (Kochen, Hinzufügen von Zutaten, Umrühren) den Zustand des Topfinhalts mit jeder Aktion.

Aktionen

Die Reduzierfunktion kann alles annehmen (einen String, ein Objekt), aber eine gängige Implementierung ist ein event Objekt mit:

  • A type (z.B. click in der DOM-Welt)

  • Optional einige payload andere Informationen über das Ereignis

Aktionen werden dann "versendet". Wenn die Aktion versendet wird, wird die entsprechende Reduzierfunktion von React mit dem aktuellen Zustand und deinem neuen Ereignis (Aktion) aufgerufen.

Mit useState hast du:

const [data, setData] = useState(initialData);

Diese kann durch das Reduzierstück ersetzt werden:

const [data, dispatch] = useReducer(myReducer, initialData);

Die data wird immer noch auf die gleiche Weise verwendet, um die Komponente zu rendern. Aber wenn etwas passiert, ruft , anstatt ein wenig Arbeit zu erledigen und dann setData() aufzurufen, die Funktion dispatch() auf, die von useReducer() zurückgegeben wird. Von dort aus übernimmt der Reducer und gibt die neue Version von data zurück. Es gibt keine weitere Funktion, die aufgerufen werden muss, um den neuen Status zu setzen; die neue data wird von React verwendet, um die Komponente neu zu rendern.

Abbildung 4-5 zeigt ein Diagramm dieses Prozesses.

rur2 0405
Abbildung 4-5. Komponenten-Dispatch-Aktion-Reduzierer-Fluss

Ein Beispiel für einen Reduzierer

Schauen wir uns ein schnelles, isoliertes Beispiel für die Verwendung eines Reduzierers an. Angenommen, du hast eine Tabelle mit zufälligen Daten und Schaltflächen, die entweder die Daten aktualisieren oder die Hintergrund- und Vordergrundfarben der Tabelle in zufällige Farben ändern können (wie in Abbildung 4-6 dargestellt).

Zu Beginn gibt es keine Daten und die Farben Schwarz und Weiß werden als Standardwerte verwendet:

const initialState = {data: [], color: 'black', background: 'white'};

Der Reducer wird am Anfang der Komponente <RandomData> initialisiert:

function RandomData() {
  const [state, dispatch] = useReducer(myReducer, initialState);
  // ...
}

Hier ist state wieder ein Sammelobjekt für verschiedene Zustandsdaten (aber das muss nicht der Fall sein). Der Rest der Komponente wird wie gewohnt auf der Grundlage von state gerendert, mit einem Unterschied. Früher war der onClick Handler eines Buttons eine Funktion, die den Status aktualisiert, jetzt rufen alle Handler nur nochdispatch()auf und senden Informationen über das Ereignis:

return (
  <div>
    <div className="toolbar">
      <button onClick={() => dispatch({type: 'newdata'})}>
        Get data
      </button>{' '}
      <button
        onClick={() => dispatch({type: 'recolor', payload: {what: 'color'}})}>
        Recolor text
      </button>{' '}
      <button
        onClick={
          () => dispatch({type: 'recolor', payload: {what: 'background'}})
      }>
        Recolor background
      </button>
    </div>
    <table style={{color, background}}>
      <tbody>
        {data.map((row, idx) => (
          <tr key={idx}>
            {row.map((cell, idx) => (
              <td key={idx}>{cell}</td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  </div>
);
rur2 0406
Abbildung 4-6. <RandomData/> Komponente(04.09.random-table-reducer.html)

Jedes versendete Ereignis-/Aktionsobjekt hat eine type Eigenschaft, damit die Reduzierfunktion erkennen kann, was getan werden muss. Es kann eine payload geben, die weitere Details des Ereignisses angibt, muss aber nicht.

Schließlich der Reducer. Er hat eine Anzahl von if/else Anweisungen (oder eine switch, wenn du das bevorzugst), die prüfen, welche Art von Ereignis gesendet wurde. Dann werden die Daten entsprechend der Aktion manipuliert und eine neue Version des Zustands wird zurückgegeben:

function myReducer(oldState, action) {
  const newState = clone(oldState);

  if (action.type === 'recolor') {
    newState[action.payload.what] =
      `rgb(${rand(256)},${rand(256)},${rand(256)})`;
  } else if (action.type === 'newdata') {
    const data = [];
    for (let i = 0; i < 10; i++) {
      data[i] = [];
      for (let j = 0; j < 10; j++) {
        data[i][j] = rand(10000);
      }
    }
    newState.data = data;
  }
  return newState;
}

// couple of helpers
function clone(o) {
  return JSON.parse(JSON.stringify(o));
}
function rand(max) {
  return Math.floor(Math.random() * max);
}

Beachte, dass der alte Zustand mit dem dir bereits bekannten clone() geklont wird. Bei useState()/setState() war das in vielen Fällen nicht unbedingt nötig. Oft reichte es aus, eine bestehende Variable zu ändern und sie an setState() zu übergeben. Aber wenn du hier nicht klonst, sondern nur das gleiche Objekt im Speicher änderst, sieht React, dass der alte und der neue Zustand auf das gleiche Objekt zeigen und überspringt das Rendern, weil es denkt, dass sich nichts geändert hat. Du kannst es selbst ausprobieren: Entferne den Aufruf von clone() und beobachte, dass das Rerendering nicht stattfindet .

Unit Testing Reducers

Der Wechsel zu useReducer() für die Verwaltung des Zustands macht es viel einfacher, Unit-Tests zu schreiben. Du musst die Komponente, ihre Eigenschaften und ihren Zustand nicht einrichten. Du musst keinen Browser einbinden oder einen anderen Weg finden, um Click-Events zu simulieren. Du musst nicht einmal React einbeziehen. Um die Zustandslogik zu testen, musst du nur den alten Zustand und eine Aktion an die Funktion reducer übergeben und prüfen, ob der gewünschte neue Zustand zurückgegeben wird. Das ist reines JavaScript: zwei Objekte rein, ein Objekt raus. Die Unit-Tests sollten nicht viel komplizierter sein als die Tests des kanonischen Beispiels:

function add(a, b) {
  return a + b;
}

Später im Buch werden wir uns mit dem Thema Testen beschäftigen, aber um dir einen Vorgeschmack zu geben, könnte ein Beispieltest so aussehen:

const initialState = {data: [], color: 'black', background: 'white'};

it('produces a 10x10 array', () => {
  const {data} = myReducer(initialState, {type: 'newdata'});
  expect(data.length).toEqual(10);
  expect(data[0].length).toEqual(10);
});

Excel-Komponente mit einem Reduzierer

Als letztes Beispiel für die Verwendung von Reduzierern sehen wir uns an, wie du vonuseState() zu useReducer() in der Komponente Excel wechseln kannst.

Im Beispiel aus dem vorigen Abschnitt war der vom Reducer verwaltete Zustand wieder ein Objekt mit unverbundenen Daten. Das muss aber nicht so sein. Du kannst mehrere Reduzierer einsetzen, um deine Anliegen zu trennen. Du kannst sogar useState() mit useReducer() mischen und kombinieren. Probieren wir das mit Excel aus.

Zuvor wurde die data in der Tabelle von useState() verwaltet:

const [data, setData] = useState(initialData);
// ...
const [edit, setEdit] = useState(null);
const [search, setSearch] = useState(false);

Wenn du zur Verwaltung von data auf useReducer() wechselst und den Rest unberührt lässt, sieht das folgendermaßen aus:

const [data, dispatch] = useReducer(reducer, initialData);
// ...
const [edit, setEdit] = useState(null);
const [search, setSearch] = useState(false);

Da data mit identisch ist, musst du im Rendering-Bereich nichts ändern. Änderungen sind nur bei den Aktionshandlern erforderlich. Zum Beispiel wird filter() verwendet, um die Filterung durchzuführen und setData() aufzurufen:

function filter(e) {
  const needle = e.target.value.toLowerCase();
  if (!needle) {
    setData(preSearchData);
    return;
  }
  const idx = e.target.dataset.idx;
  const searchdata = preSearchData.filter((row) => {
    return row[idx].toString().toLowerCase().indexOf(needle) > -1;
  });
  setData(searchdata);
}

In der umgeschriebenen Version wird stattdessen eine Aktion ausgelöst. Das Ereignis hat eine type von "search" und einige zusätzliche Nutzdaten (wonach sucht der Nutzer und wo?):

function filter(e) {
  const needle = e.target.value;
  const column = e.target.dataset.idx;
  dispatch({
    type: 'search',
    payload: {needle, column},
  });
  setEdit(null);
}

Ein weiteres Beispiel wäre das Umschalten der Suchfelder:

// before
function toggleSearch() {
  if (search) {
    setData(preSearchData);
    setSearch(false);
    setPreSearchData(null);
  } else {
    setPreSearchData(data);
    setSearch(true);
  }
}


// after
function toggleSearch() {
  if (!search) {
    dispatch({type: 'startSearching'});
  } else {
    dispatch({type: 'doneSearching'});
  }
  setSearch(!search);
}

Hier kannst du die Mischung aus setSearch() und dispatch() sehen, um den Zustand zu verwalten. Der!search Toggle ist ein Flag für die Benutzeroberfläche, um Eingabefelder ein- oder auszublenden, während der dispatch() für die Verwaltung der Daten ist.

Werfen wir zum Schluss einen Blick auf die Funktion reducer() . Hier werden die Daten gefiltert und manipuliert. Auch hier handelt es sich um eine Reihe von if/else Blöcken, die jeweils einen anderen Aktionstyp behandeln:

let originalData = null;

function reducer(data, action) {
  if (action.type === 'sort') {
    const {column, descending} = action.payload;
    return clone(data).sort((a, b) => {
      if (a[column] === b[column]) {
        return 0;
      }
      return descending
        ? a[column] < b[column]
          ? 1
          : -1
        : a[column] > b[column]
          ? 1
          : -1;
    });
  }
  if (action.type === 'save') {
    data[action.payload.edit.row][action.payload.edit.column] =
      action.payload.value;
    return data;
  }
  if (action.type === 'startSearching') {
    originalData = data;
    return originalData;
  }
  if (action.type === 'doneSearching') {
    return originalData;
  }
  if (action.type === 'search') {
    return originalData.filter((row) => {
      return (
        row[action.payload.column]
          .toString()
          .toLowerCase()
          .indexOf(action.payload.needle.toLowerCase()) > -1
      );
    });
  }
}

Get React: Up & Running, 2. Auflage now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.